Sitelet https://github.com/snatuva/angular-primitives/commit/69bc4dfc1287b9232dc271a6aae1d9457b336391
Skip to content

Commit 69bc4df

Browse files
authored
test: add cleanup tests for Accordion, Dialog, Tabs, and Tooltip components (#9)
1 parent ddfb4c7 commit 69bc4df

8 files changed

Lines changed: 308 additions & 5 deletions

File tree

‎projects/primitives/src/lib/accordion/accordion.spec.ts‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,4 +329,76 @@ describe('AccordionDirective', () => {
329329

330330
expect(document.activeElement).toBe(triggers[triggers.length - 1]);
331331
});
332+
333+
// -------------------------------------------------------------------------
334+
// Cleanup / ngOnDestroy
335+
// -------------------------------------------------------------------------
336+
337+
it('should not leak expanded state when multiple instances created and destroyed', async () => {
338+
const triggers1 = getTriggers(fixture);
339+
triggers1[0].click();
340+
fixture.detectChanges();
341+
await fixture.whenStable();
342+
343+
expect(triggers1[0].getAttribute('aria-expanded')).toBe('true');
344+
345+
// Destroy component
346+
fixture.destroy();
347+
348+
// Create new fixture
349+
const fixture2 = TestBed.createComponent(TestAccordionComponent);
350+
const component2 = fixture2.componentInstance;
351+
fixture2.detectChanges();
352+
353+
const triggers2 = getTriggers(fixture2);
354+
expect(triggers2[0].getAttribute('aria-expanded')).toBe('false');
355+
});
356+
357+
it('should maintain expanded state integrity on trigger destruction', async () => {
358+
const triggers = getTriggers(fixture);
359+
triggers[0].click();
360+
fixture.detectChanges();
361+
await fixture.whenStable();
362+
363+
const accordionDir = fixture.debugElement.query(By.directive(AccordionDirective))
364+
.injector.get(AccordionDirective);
365+
366+
expect(accordionDir.isExpanded('item-1')).toBe(true);
367+
368+
// Simulate destroying the component
369+
fixture.destroy();
370+
371+
// Verify no error
372+
expect(() => {
373+
const accordion = fixture.debugElement.query(By.directive(AccordionDirective));
374+
// After destroy, accordion ref should be null
375+
expect(accordion).toBeFalsy();
376+
}).not.toThrow();
377+
});
378+
379+
it('should handle signal cleanup without memory leaks', async () => {
380+
const triggers = getTriggers(fixture);
381+
const accordionDir = fixture.debugElement.query(By.directive(AccordionDirective))
382+
.injector.get(AccordionDirective);
383+
384+
// Multiple expansions to stress-test signal management
385+
triggers[0].click();
386+
fixture.detectChanges();
387+
await fixture.whenStable();
388+
389+
triggers[1].click();
390+
fixture.detectChanges();
391+
await fixture.whenStable();
392+
393+
// Verify state is correct
394+
expect(triggers[0].getAttribute('aria-expanded')).toBe('false');
395+
expect(triggers[1].getAttribute('aria-expanded')).toBe('true');
396+
397+
// Cleanup should not error
398+
fixture.destroy();
399+
expect(() => {
400+
const accordion = fixture.debugElement.query(By.directive(AccordionDirective));
401+
expect(accordion).toBeFalsy();
402+
}).not.toThrow();
403+
});
332404
});

‎projects/primitives/src/lib/dialog/dialog.spec.ts‎

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@ class TestDialogComponent {
5252
role = signal<'dialog' | 'alertdialog'>('dialog');
5353
lastOpenChange: boolean | null = null;
5454

55-
onOpenChange(v: boolean) {
56-
this.lastOpenChange = v;
55+
onOpenChange(v: boolean) {
56+
this.lastOpenChange = v;
5757
this.controlledOpen.set(v);
5858
}
5959
}
@@ -310,4 +310,46 @@ describe('DialogDirective', () => {
310310
expect(getPanel(fixture).getAttribute('data-state')).toBe('open');
311311
expect(getOverlay(fixture).getAttribute('data-state')).toBe('open');
312312
});
313+
314+
// -------------------------------------------------------------------------
315+
// Cleanup / ngOnDestroy
316+
// -------------------------------------------------------------------------
317+
318+
it('should close the dialog on component destroy', async () => {
319+
const dialogDir = fixture.debugElement.query(By.directive(DialogDirective))
320+
.injector.get(DialogDirective);
321+
322+
// Open the dialog
323+
getTrigger(fixture).click();
324+
fixture.detectChanges();
325+
await new Promise(r => setTimeout(r, 0));
326+
expect(getPanel(fixture).hasAttribute('hidden')).toBe(false);
327+
328+
// Destroy the directive
329+
dialogDir.ngOnDestroy();
330+
fixture.detectChanges();
331+
332+
// Verify dialog closed
333+
expect(dialogDir.open()).toBe(false);
334+
});
335+
336+
it('should reset open state to false on destroy if already open', () => {
337+
const dialogDir = fixture.debugElement.query(By.directive(DialogDirective))
338+
.injector.get(DialogDirective);
339+
340+
dialogDir.openDialog();
341+
expect(dialogDir.open()).toBe(true);
342+
343+
dialogDir.ngOnDestroy();
344+
345+
expect(dialogDir.open()).toBe(false);
346+
});
347+
348+
it('should not throw error on destroy if dialog is closed', () => {
349+
const dialogDir = fixture.debugElement.query(By.directive(DialogDirective))
350+
.injector.get(DialogDirective);
351+
352+
expect(dialogDir.open()).toBe(false);
353+
expect(() => dialogDir.ngOnDestroy()).not.toThrow();
354+
});
313355
});

‎projects/primitives/src/lib/tabs/index.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,3 @@ export * from './tab-panel.directive';
33
export * from './tab-trigger.directive';
44
export * from './tab-content.directive';
55
export * from './tabs-list.directive';
6-
export * from './tabs.harness';

‎projects/primitives/src/lib/tabs/tabs.directive.spec.ts‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { Component } from '@angular/core';
22
import { ComponentFixture, TestBed } from '@angular/core/testing';
33
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
4+
import { By } from '@angular/platform-browser';
45
import { TabsDirective } from './tabs.directive';
56
import { TabTriggerDirective } from './tab-trigger.directive';
67
import { TabPanelDirective } from './tab-panel.directive';
78
import { TabListDirective } from './tabs-list.directive';
89
import { TabsHarness } from './tabs.harness';
10+
import { TabsState } from './tabs.state';
911

1012
@Component({
1113
template: `
@@ -65,4 +67,105 @@ describe('TabsDirective Accessibility', () => {
6567
const activeTab = await harness.getActiveTab();
6668
expect(await activeTab?.isActive()).toBe(true);
6769
});
70+
71+
// -------------------------------------------------------------------------
72+
// Cleanup / ngOnDestroy
73+
// -------------------------------------------------------------------------
74+
75+
it('should maintain tab state isolation across component instances', async () => {
76+
// First instance
77+
const fixture1 = TestBed.createComponent(TestTabsComponent);
78+
const harness1 = await TestbedHarnessEnvironment.harnessForFixture(fixture1, TabsHarness);
79+
80+
await harness1.selectTabByText('Tab 2');
81+
let activeTab = await harness1.getActiveTab();
82+
expect(await activeTab?.getText()).toBe('Tab 2');
83+
84+
fixture1.destroy();
85+
86+
// Second instance should start fresh
87+
const fixture2 = TestBed.createComponent(TestTabsComponent);
88+
const harness2 = await TestbedHarnessEnvironment.harnessForFixture(fixture2, TabsHarness);
89+
90+
activeTab = await harness2.getActiveTab();
91+
expect(await activeTab?.getText()).toBe('Tab 1'); // Should be first tab
92+
});
93+
94+
it('should clean up keyboard listeners on destroy', async () => {
95+
const fixture = TestBed.createComponent(TestTabsComponent);
96+
const harness = await TestbedHarnessEnvironment.harnessForFixture(fixture, TabsHarness);
97+
98+
const triggers = await harness.getTabTriggers();
99+
expect(triggers.length).toBeGreaterThan(0);
100+
101+
// Just select a tab to engage the directive
102+
await harness.selectTabByText('Tab 2');
103+
104+
// Destroy component
105+
fixture.destroy();
106+
107+
// Should not error on subsequent operations
108+
expect(() => {
109+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
110+
}).not.toThrow();
111+
});
112+
113+
it('should not leak tab state on component destruction', async () => {
114+
const fixture = TestBed.createComponent(TestTabsComponent);
115+
const harness = await TestbedHarnessEnvironment.harnessForFixture(fixture, TabsHarness);
116+
117+
await harness.selectTabByText('Tab 2');
118+
let activeTab = await harness.getActiveTab();
119+
expect(await activeTab?.getText()).toBe('Tab 2');
120+
121+
// Verify tabs are in DOM
122+
const panels1 = await harness.getTabPanels();
123+
expect(panels1.length).toBe(2);
124+
125+
// Destroy
126+
fixture.destroy();
127+
128+
// Create new fixture with clean state
129+
const fixture2 = TestBed.createComponent(TestTabsComponent);
130+
const harness2 = await TestbedHarnessEnvironment.harnessForFixture(fixture2, TabsHarness);
131+
132+
// First tab should be active in new instance
133+
activeTab = await harness2.getActiveTab();
134+
expect(await activeTab?.getText()).toBe('Tab 1');
135+
136+
const panels2 = await harness2.getTabPanels();
137+
expect(panels2.length).toBe(2);
138+
});
139+
140+
it('should safely destroy even if no tabs are selected', () => {
141+
const fixture = TestBed.createComponent(TestTabsComponent);
142+
143+
expect(() => {
144+
fixture.destroy();
145+
}).not.toThrow();
146+
});
147+
148+
it('should unsubscribe from all effects on destroy', async () => {
149+
const fixture = TestBed.createComponent(TestTabsComponent);
150+
151+
// Get the state service injected into the directive
152+
const tabsDir = fixture.debugElement.query(By.directive(TabsDirective));
153+
const tabsState = tabsDir?.injector.get(TabsState);
154+
155+
if (tabsState) {
156+
// Verify initial state
157+
expect(tabsState).toBeTruthy();
158+
159+
// Destroy
160+
fixture.destroy();
161+
162+
// Creating new fixture should not be affected by old state
163+
const fixture2 = TestBed.createComponent(TestTabsComponent);
164+
const tabsState2 = fixture2.debugElement.query(By.directive(TabsDirective))
165+
?.injector.get(TabsState);
166+
167+
expect(tabsState2).toBeTruthy();
168+
expect(tabsState).not.toBe(tabsState2);
169+
}
170+
});
68171
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './tabs.harness';
Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
11
export * from './tooltip-trigger.directive';
22
export * from './tooltip-content.directive';
33
export * from './tooltip.directive';
4-
export * from './tooltip.state';
5-
export * from './tooltip.harness';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './tooltip.harness';

‎projects/primitives/src/lib/tooltip/tooltip.directive.spec.ts‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Component } from '@angular/core';
22
import { ComponentFixture, TestBed } from '@angular/core/testing';
33
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
4+
import { By } from '@angular/platform-browser';
45
import { TooltipDirective } from './tooltip.directive';
56
import { TooltipTriggerDirective } from './tooltip-trigger.directive';
67
import { TooltipContentDirective } from './tooltip-content.directive';
@@ -193,4 +194,90 @@ describe('TooltipDirective Accessibility', () => {
193194

194195
// expect(await harnessNoContent.isOpen()).toBe(false);
195196
// });
197+
198+
// -------------------------------------------------------------------------
199+
// Cleanup / ngOnDestroy
200+
// -------------------------------------------------------------------------
201+
202+
it('should destroy tooltip overlay on component destroy', async () => {
203+
const fixture = TestBed.createComponent(TestTooltipComponent);
204+
const harness = await TestbedHarnessEnvironment.harnessForFixture(fixture, TooltipHarness);
205+
206+
// Open tooltip
207+
const trigger = await harness.getTrigger();
208+
await trigger.focus();
209+
await new Promise(resolve => setTimeout(resolve, 400));
210+
211+
expect(await harness.isOpen()).toBe(true);
212+
213+
// Get the tooltip element before destroy
214+
let tooltipElement = document.querySelector('[role="tooltip"]');
215+
expect(tooltipElement).toBeTruthy();
216+
217+
// Destroy component
218+
fixture.destroy();
219+
220+
// Verify tooltip is removed from DOM
221+
tooltipElement = document.querySelector('[role="tooltip"]');
222+
expect(tooltipElement).toBeFalsy();
223+
});
224+
225+
it('should clear all timers on trigger destroy', async () => {
226+
const fixture = TestBed.createComponent(TestTooltipComponent);
227+
const harness = await TestbedHarnessEnvironment.harnessForFixture(fixture, TooltipHarness);
228+
229+
// Simulate mouse enter (starts timeout)
230+
const trigger = await harness.getTrigger();
231+
await trigger.hover();
232+
233+
// Immediately destroy before timeout completes
234+
fixture.destroy();
235+
236+
// Wait for original timeout
237+
await new Promise(resolve => setTimeout(resolve, 400));
238+
239+
// If no error and tooltip is not in DOM, cleanup worked
240+
const tooltipElement = document.querySelector('[role="tooltip"]');
241+
expect(tooltipElement).toBeFalsy();
242+
});
243+
244+
it('should unsubscribe from document keydown on destroy', async () => {
245+
const fixture = TestBed.createComponent(TestTooltipComponent);
246+
const tooltipDir = fixture.debugElement.query(By.directive(TooltipDirective));
247+
248+
if (tooltipDir) {
249+
const directive = tooltipDir.injector.get(TooltipDirective);
250+
251+
// Destroy
252+
directive.ngOnDestroy();
253+
254+
// Simulate escape key after destroy
255+
const event = new KeyboardEvent('keydown', { key: 'Escape' });
256+
document.dispatchEvent(event);
257+
258+
// Should not error; listener should be unregistered
259+
expect(() => {
260+
document.dispatchEvent(event);
261+
}).not.toThrow();
262+
}
263+
});
264+
265+
it('should not leak subscriptions across multiple instances', async () => {
266+
const fixture1 = TestBed.createComponent(TestTooltipComponent);
267+
await TestbedHarnessEnvironment.harnessForFixture(fixture1, TooltipHarness);
268+
269+
fixture1.destroy();
270+
271+
const fixture2 = TestBed.createComponent(TestTooltipComponent);
272+
const harness2 = await TestbedHarnessEnvironment.harnessForFixture(fixture2, TooltipHarness);
273+
274+
const trigger2 = await harness2.getTrigger();
275+
await trigger2.focus();
276+
await new Promise(resolve => setTimeout(resolve, 400));
277+
278+
// Should work independently without interference
279+
expect(await harness2.isOpen()).toBe(true);
280+
281+
fixture2.destroy();
282+
});
196283
});

0 commit comments

Comments
 (0)