diff --git a/src/presentation/components/Scripts/Slider/UseDragHandler.ts b/src/presentation/components/Scripts/Slider/UseDragHandler.ts index 1b813894..8d900072 100644 --- a/src/presentation/components/Scripts/Slider/UseDragHandler.ts +++ b/src/presentation/components/Scripts/Slider/UseDragHandler.ts @@ -3,6 +3,7 @@ import { } from 'vue'; import { throttle } from '@/application/Common/Timing/Throttle'; import type { Ref } from 'vue'; +import type { LifecycleHook } from '../../Shared/Hooks/Common/LifecycleHook'; const ThrottleInMs = 15; @@ -10,6 +11,7 @@ export function useDragHandler( draggableElementRef: Readonly>, dragDomModifier: DragDomModifier = new GlobalDocumentDragDomModifier(), throttler = throttle, + onTeardown: LifecycleHook = onUnmounted, ) { const displacementX = ref(0); const isDragging = ref(false); @@ -52,7 +54,7 @@ export function useDragHandler( element.addEventListener('pointerdown', startDrag); } - onUnmounted(() => { + onTeardown(() => { stopDrag(); }); diff --git a/src/presentation/components/Scripts/Slider/UseGlobalCursor.ts b/src/presentation/components/Scripts/Slider/UseGlobalCursor.ts index dafd7546..d8dbb9c2 100644 --- a/src/presentation/components/Scripts/Slider/UseGlobalCursor.ts +++ b/src/presentation/components/Scripts/Slider/UseGlobalCursor.ts @@ -1,9 +1,11 @@ import { watch, type Ref, onUnmounted } from 'vue'; +import type { LifecycleHook } from '../../Shared/Hooks/Common/LifecycleHook'; export function useGlobalCursor( isActive: Readonly>, cursorCssValue: string, documentAccessor: CursorStyleDomModifier = new GlobalDocumentCursorStyleDomModifier(), + onTeardown: LifecycleHook = onUnmounted, ) { const cursorStyle = createCursorStyle(cursorCssValue, documentAccessor); @@ -15,7 +17,7 @@ export function useGlobalCursor( } }); - onUnmounted(() => { + onTeardown(() => { documentAccessor.removeElement(cursorStyle); }); } diff --git a/src/presentation/components/Shared/Hooks/Common/LifecycleHook.ts b/src/presentation/components/Shared/Hooks/Common/LifecycleHook.ts new file mode 100644 index 00000000..fb250344 --- /dev/null +++ b/src/presentation/components/Shared/Hooks/Common/LifecycleHook.ts @@ -0,0 +1,8 @@ +/* + These types are used to abstract Vue Lifecycle injection APIs + (e.g., onBeforeMount, onUnmount) for better testability. +*/ + +export type LifecycleHook = (callback: LifecycleHookCallback) => void; + +export type LifecycleHookCallback = () => void; diff --git a/src/presentation/components/Shared/Hooks/Resize/UseResizeObserver.ts b/src/presentation/components/Shared/Hooks/Resize/UseResizeObserver.ts index 0ee22f8b..be7e5ac1 100644 --- a/src/presentation/components/Shared/Hooks/Resize/UseResizeObserver.ts +++ b/src/presentation/components/Shared/Hooks/Resize/UseResizeObserver.ts @@ -5,14 +5,15 @@ import { import { throttle, type ThrottleFunction } from '@/application/Common/Timing/Throttle'; import { useResizeObserverPolyfill } from './UseResizeObserverPolyfill'; import { useAnimationFrameLimiter } from './UseAnimationFrameLimiter'; +import type { LifecycleHook } from '../Common/LifecycleHook'; export function useResizeObserver( config: ResizeObserverConfig, usePolyfill = useResizeObserverPolyfill, useFrameLimiter = useAnimationFrameLimiter, throttler: ThrottleFunction = throttle, - onSetup: LifecycleHookRegistration = onBeforeMount, - onTeardown: LifecycleHookRegistration = onBeforeUnmount, + onSetup: LifecycleHook = onBeforeMount, + onTeardown: LifecycleHook = onBeforeUnmount, ) { const { resetNextFrame, cancelNextFrame } = useFrameLimiter(); // This prevents the 'ResizeObserver loop completed with undelivered notifications' error when @@ -63,5 +64,3 @@ export interface ResizeObserverConfig { } export type ObservedElementReference = Readonly>; - -export type LifecycleHookRegistration = (callback: () => void) => void; diff --git a/src/presentation/components/Shared/Hooks/UseAutoUnsubscribedEventListener.ts b/src/presentation/components/Shared/Hooks/UseAutoUnsubscribedEventListener.ts index 5db50ce0..4ce57125 100644 --- a/src/presentation/components/Shared/Hooks/UseAutoUnsubscribedEventListener.ts +++ b/src/presentation/components/Shared/Hooks/UseAutoUnsubscribedEventListener.ts @@ -4,12 +4,17 @@ import { watch, type Ref, } from 'vue'; +import type { LifecycleHook } from './Common/LifecycleHook'; export interface UseEventListener { - (): TargetEventListener; + ( + onTeardown?: LifecycleHook, + ): TargetEventListener; } -export const useAutoUnsubscribedEventListener: UseEventListener = () => ({ +export const useAutoUnsubscribedEventListener: UseEventListener = ( + onTeardown = onBeforeUnmount, +) => ({ startListening: (eventTargetSource, eventType, eventHandler) => { const eventTargetRef = isEventTarget(eventTargetSource) ? shallowRef(eventTargetSource) @@ -18,6 +23,7 @@ export const useAutoUnsubscribedEventListener: UseEventListener = () => ({ eventTargetRef, eventType, eventHandler, + onTeardown, ); }, }); @@ -42,6 +48,7 @@ function startListeningRef( eventTargetRef: Readonly>, eventType: TEvent, eventHandler: (event: HTMLElementEventMap[TEvent]) => void, + onTeardown: LifecycleHook, ): void { const eventListenerManager = new EventListenerManager(); watch(() => eventTargetRef.value, (element) => { @@ -52,7 +59,7 @@ function startListeningRef( eventListenerManager.addListener(element, eventType, eventHandler); }, { immediate: true }); - onBeforeUnmount(() => { + onTeardown(() => { eventListenerManager.removeListenerIfExists(); }); } diff --git a/src/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents.ts b/src/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents.ts index 59b46961..c2bb6ea0 100644 --- a/src/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents.ts +++ b/src/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents.ts @@ -1,15 +1,17 @@ import { onUnmounted } from 'vue'; import { EventSubscriptionCollection } from '@/infrastructure/Events/EventSubscriptionCollection'; import type { IEventSubscriptionCollection } from '@/infrastructure/Events/IEventSubscriptionCollection'; +import type { LifecycleHook } from './Common/LifecycleHook'; export function useAutoUnsubscribedEvents( events: IEventSubscriptionCollection = new EventSubscriptionCollection(), + onTeardown: LifecycleHook = onUnmounted, ) { if (events.subscriptionCount > 0) { throw new Error('there are existing subscriptions, this may lead to side-effects'); } - onUnmounted(() => { + onTeardown(() => { events.unsubscribeAll(); }); diff --git a/tests/integration/composite/DependencyResolution.spec.ts b/tests/integration/composite/DependencyResolution.spec.ts index 68d7161c..d3033fdf 100644 --- a/tests/integration/composite/DependencyResolution.spec.ts +++ b/tests/integration/composite/DependencyResolution.spec.ts @@ -1,10 +1,10 @@ import { it, describe, expect } from 'vitest'; -import { shallowMount } from '@vue/test-utils'; -import { defineComponent, inject } from 'vue'; +import { inject } from 'vue'; import { type InjectionKeySelector, InjectionKeys, injectKey } from '@/presentation/injectionSymbols'; import { provideDependencies } from '@/presentation/bootstrapping/DependencyProvider'; import { buildContext } from '@/application/Context/ApplicationContextFactory'; import type { IApplicationContext } from '@/application/Context/IApplicationContext'; +import { executeInComponentSetupContext } from '@tests/shared/Vue/ExecuteInComponentSetupContext'; describe('DependencyResolution', () => { describe('all dependencies can be injected', async () => { @@ -16,7 +16,7 @@ describe('DependencyResolution', () => { // act const resolvedDependency = resolve(() => key, dependencies); // assert - expect(resolvedDependency).to.toBeDefined(); + expect(resolvedDependency).toBeDefined(); }); }); }); @@ -40,13 +40,14 @@ function resolve( providedKeys: ProvidedKeys, ): T | undefined { let injectedDependency: T | undefined; - shallowMount(defineComponent({ - setup() { + executeInComponentSetupContext({ + setupCallback: () => { injectedDependency = injectKey(selector); }, - }), { - global: { - provide: providedKeys, + mountOptions: { + global: { + provide: providedKeys, + }, }, }); return injectedDependency; diff --git a/tests/integration/presentation/components/Scripts/View/Tree/Shared/Icon/UseSvgLoader.spec.ts b/tests/integration/presentation/components/Scripts/View/Tree/Shared/Icon/UseSvgLoader.spec.ts index 992d909e..21e10a2b 100644 --- a/tests/integration/presentation/components/Scripts/View/Tree/Shared/Icon/UseSvgLoader.spec.ts +++ b/tests/integration/presentation/components/Scripts/View/Tree/Shared/Icon/UseSvgLoader.spec.ts @@ -3,7 +3,7 @@ import { } from 'vitest'; import { IconNames } from '@/presentation/components/Shared/Icon/IconName'; import { useSvgLoader } from '@/presentation/components/Shared/Icon/UseSvgLoader'; -import { waitForValueChange } from '@tests/shared/WaitForValueChange'; +import { waitForValueChange } from '@tests/shared/Vue/WaitForValueChange'; describe('useSvgLoader', () => { describe('can load all SVGs', () => { diff --git a/tests/integration/presentation/components/Scripts/View/Tree/TreeView/Node/UseKeyboardInteractionState.spec.ts b/tests/integration/presentation/components/Scripts/View/Tree/TreeView/Node/UseKeyboardInteractionState.spec.ts index 956ed9c3..5bab859c 100644 --- a/tests/integration/presentation/components/Scripts/View/Tree/TreeView/Node/UseKeyboardInteractionState.spec.ts +++ b/tests/integration/presentation/components/Scripts/View/Tree/TreeView/Node/UseKeyboardInteractionState.spec.ts @@ -1,8 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { shallowMount } from '@vue/test-utils'; -import { defineComponent } from 'vue'; import { useKeyboardInteractionState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/UseKeyboardInteractionState'; import { expectExists } from '@tests/shared/Assertions/ExpectExists'; +import { executeInComponentSetupContext } from '@tests/shared/Vue/ExecuteInComponentSetupContext'; describe('useKeyboardInteractionState', () => { describe('isKeyboardBeingUsed', () => { @@ -49,12 +48,12 @@ function triggerKeyPress() { function mountWrapperComponent() { let returnObject: ReturnType | undefined; - const wrapper = shallowMount(defineComponent({ - setup() { + const wrapper = executeInComponentSetupContext({ + setupCallback: () => { returnObject = useKeyboardInteractionState(); }, - template: '
', - })); + disableAutoUnmount: true, + }); expectExists(returnObject); return { returnObject, diff --git a/tests/shared/Vue/ExecuteInComponentSetupContext.ts b/tests/shared/Vue/ExecuteInComponentSetupContext.ts new file mode 100644 index 00000000..532d28f2 --- /dev/null +++ b/tests/shared/Vue/ExecuteInComponentSetupContext.ts @@ -0,0 +1,27 @@ +import { shallowMount, type ComponentMountingOptions } from '@vue/test-utils'; +import { defineComponent } from 'vue'; + +type MountOptions = ComponentMountingOptions; + +/** + * A test helper utility that provides a component `setup()` context. + * This function allows running code that depends on Vue lifecycle hooks, + * such as `onMounted`, within a component's `setup` function. + */ +export function executeInComponentSetupContext(options: { + readonly setupCallback: () => void; + readonly disableAutoUnmount?: boolean; + readonly mountOptions?: MountOptions, +}): ReturnType { + const componentWrapper = shallowMount(defineComponent({ + setup() { + options.setupCallback(); + }, + // Component requires a template or render function + template: '
Test Component: setup context
', + }), options.mountOptions); + if (!options.disableAutoUnmount) { + componentWrapper.unmount(); // Ensure cleanup of callback tasks + } + return componentWrapper; +} diff --git a/tests/shared/WaitForValueChange.ts b/tests/shared/Vue/WaitForValueChange.ts similarity index 100% rename from tests/shared/WaitForValueChange.ts rename to tests/shared/Vue/WaitForValueChange.ts diff --git a/tests/shared/bootstrap/FailTestOnConsoleError.ts b/tests/shared/bootstrap/FailTestOnConsoleError.ts new file mode 100644 index 00000000..7267fef8 --- /dev/null +++ b/tests/shared/bootstrap/FailTestOnConsoleError.ts @@ -0,0 +1,23 @@ +import { + beforeEach, afterEach, vi, expect, +} from 'vitest'; +import type { FunctionKeys } from '@/TypeHelpers'; + +export function failTestOnConsoleError() { + const consoleMethodsToCheck: readonly FunctionKeys[] = [ + 'warn', + 'error', + ]; + + beforeEach(() => { + consoleMethodsToCheck.forEach((methodName) => { + vi.spyOn(console, methodName).mockClear(); + }); + }); + + afterEach(() => { + consoleMethodsToCheck.forEach((methodName) => { + expect(console[methodName]).not.toHaveBeenCalled(); + }); + }); +} diff --git a/tests/shared/bootstrap/setup.ts b/tests/shared/bootstrap/setup.ts index ff5d02ec..4c25f7a0 100644 --- a/tests/shared/bootstrap/setup.ts +++ b/tests/shared/bootstrap/setup.ts @@ -1,6 +1,8 @@ import { afterEach } from 'vitest'; import { enableAutoUnmount } from '@vue/test-utils'; import { polyfillBlob } from './BlobPolyfill'; +import { failTestOnConsoleError } from './FailTestOnConsoleError'; enableAutoUnmount(afterEach); polyfillBlob(); +failTestOnConsoleError(); diff --git a/tests/unit/presentation/bootstrapping/DependencyProvider.spec.ts b/tests/unit/presentation/bootstrapping/DependencyProvider.spec.ts index 767594aa..a2abeaae 100644 --- a/tests/unit/presentation/bootstrapping/DependencyProvider.spec.ts +++ b/tests/unit/presentation/bootstrapping/DependencyProvider.spec.ts @@ -6,6 +6,7 @@ import { ApplicationContextStub } from '@tests/unit/shared/Stubs/ApplicationCont import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests'; import type { IApplicationContext } from '@/application/Context/IApplicationContext'; import { itIsTransientFactory } from '@tests/unit/shared/TestCases/TransientFactoryTests'; +import { executeInComponentSetupContext } from '@tests/shared/Vue/ExecuteInComponentSetupContext'; describe('DependencyProvider', () => { describe('provideDependencies', () => { @@ -55,9 +56,14 @@ function createTransientTests() { .provideDependencies(); // act const getFactoryResult = () => { - const registeredObject = api.inject(injectionKey); - const factory = registeredObject as () => unknown; - return factory(); + const registeredFactory = api.inject(injectionKey) as () => unknown; + let factoryResult: unknown; + executeInComponentSetupContext({ + setupCallback: () => { + factoryResult = registeredFactory(); + }, + }); + return factoryResult; }; // assert itIsTransientFactory({ @@ -97,6 +103,7 @@ function createSingletonTests() { }); }; } + class ProvideDependenciesBuilder { private context: IApplicationContext = new ApplicationContextStub(); diff --git a/tests/unit/presentation/components/Scripts/Menu/Recommendation/Rating/CircleRating.spec.ts b/tests/unit/presentation/components/Scripts/Menu/Recommendation/Rating/CircleRating.spec.ts index c1f9b6da..1435cd95 100644 --- a/tests/unit/presentation/components/Scripts/Menu/Recommendation/Rating/CircleRating.spec.ts +++ b/tests/unit/presentation/components/Scripts/Menu/Recommendation/Rating/CircleRating.spec.ts @@ -13,23 +13,10 @@ describe('CircleRating.vue', () => { const currentRating = MAX_RATING - 1; // act - const wrapper = shallowMount(CircleRating, { - propsData: { - rating: currentRating, - }, + const wrapper = mountComponent({ + rating: currentRating, }); - // assert - const ratingCircles = wrapper.findAllComponents(RatingCircle); - expect(ratingCircles.length).to.equal(expectedMaxRating); - }); - it('renders the correct number of RatingCircle components for default rating', () => { - // arrange - const expectedMaxRating = MAX_RATING; - - // act - const wrapper = shallowMount(CircleRating); - // assert const ratingCircles = wrapper.findAllComponents(RatingCircle); expect(ratingCircles.length).to.equal(expectedMaxRating); @@ -42,10 +29,8 @@ describe('CircleRating.vue', () => { const expectedTotalComponents = 3; // act - const wrapper = shallowMount(CircleRating, { - propsData: { - rating: expectedTotalComponents, - }, + const wrapper = mountComponent({ + rating: expectedTotalComponents, }); // assert @@ -87,3 +72,13 @@ describe('CircleRating.vue', () => { }); }); }); + +function mountComponent(options: { + readonly rating: number, +}) { + return shallowMount(CircleRating, { + props: { + rating: options.rating, + }, + }); +} diff --git a/tests/unit/presentation/components/Scripts/Slider/UseDragHandler.spec.ts b/tests/unit/presentation/components/Scripts/Slider/UseDragHandler.spec.ts index b06bdd98..8a5ebf88 100644 --- a/tests/unit/presentation/components/Scripts/Slider/UseDragHandler.spec.ts +++ b/tests/unit/presentation/components/Scripts/Slider/UseDragHandler.spec.ts @@ -4,6 +4,8 @@ import { useDragHandler, type DragDomModifier } from '@/presentation/components/ import { ThrottleStub } from '@tests/unit/shared/Stubs/ThrottleStub'; import { type ThrottleFunction } from '@/application/Common/Timing/Throttle'; import type { ConstructorArguments } from '@/TypeHelpers'; +import type { LifecycleHook } from '@/presentation/components/Shared/Hooks/Common/LifecycleHook'; +import { LifecycleHookStub } from '@tests/unit/shared/Stubs/LifecycleHookStub'; describe('useDragHandler', () => { describe('initially', () => { @@ -80,7 +82,7 @@ describe('useDragHandler', () => { const finalDragX = 150; const expectedDisplacementX = finalDragX - initialDragX; const mockElement = document.createElement('div'); - const dragDomModifierMock = new DragDomModifierMock(); + const dragDomModifierMock = createDragDomModifierMock(); // act const { displacementX } = initializeDragHandlerWithMocks({ @@ -100,7 +102,7 @@ describe('useDragHandler', () => { 'pointermove', ]; const mockElement = document.createElement('div'); - const dragDomModifierMock = new DragDomModifierMock(); + const dragDomModifierMock = createDragDomModifierMock(); // act initializeDragHandlerWithMocks({ @@ -153,7 +155,7 @@ describe('useDragHandler', () => { const expectedTotalThrottledEvents = 3; const throttleStub = new ThrottleStub(); const mockElement = document.createElement('div'); - const dragDomModifierMock = new DragDomModifierMock(); + const dragDomModifierMock = createDragDomModifierMock(); // act initializeDragHandlerWithMocks({ @@ -176,7 +178,7 @@ describe('useDragHandler', () => { const mockElement = document.createElement('div'); const initialDragX = 100; const firstDisplacementX = 10; - const dragDomModifierMock = new DragDomModifierMock(); + const dragDomModifierMock = createDragDomModifierMock(); // act const { displacementX } = initializeDragHandlerWithMocks({ @@ -199,7 +201,7 @@ describe('useDragHandler', () => { // arrange const expectedDraggingState = false; const mockElement = document.createElement('div'); - const dragDomModifierMock = new DragDomModifierMock(); + const dragDomModifierMock = createDragDomModifierMock(); // act const { isDragging } = initializeDragHandlerWithMocks({ @@ -215,7 +217,7 @@ describe('useDragHandler', () => { it('removes event listeners', () => { // arrange const mockElement = document.createElement('div'); - const dragDomModifierMock = new DragDomModifierMock(); + const dragDomModifierMock = createDragDomModifierMock(); // act initializeDragHandlerWithMocks({ @@ -225,6 +227,27 @@ describe('useDragHandler', () => { mockElement.dispatchEvent(createMockPointerEvent('pointerdown', { clientX: 100 })); dragDomModifierMock.simulateEvent('pointerup', createMockPointerEvent('pointerup')); + // assert + const actualEvents = [...dragDomModifierMock.events]; + expect(actualEvents).to.have.lengthOf(0); + }); + }); + describe('on teardown', () => { + it('removes event listeners', () => { + // arrange + const teardownHook = new LifecycleHookStub(); + const mockElement = document.createElement('div'); + const dragDomModifierMock = createDragDomModifierMock(); + + // act + initializeDragHandlerWithMocks({ + draggableElementRef: ref(mockElement), + dragDomModifier: dragDomModifierMock, + onTeardown: teardownHook.getHook(), + }); + mockElement.dispatchEvent(createMockPointerEvent('pointerdown', { clientX: 100 })); + teardownHook.executeAllCallbacks(); + // assert const actualEvents = [...dragDomModifierMock.events]; expect(actualEvents).to.have.lengthOf(0); @@ -236,11 +259,13 @@ function initializeDragHandlerWithMocks(mocks?: { readonly dragDomModifier?: DragDomModifier; readonly draggableElementRef?: Ref; readonly throttler?: ThrottleFunction, + readonly onTeardown?: LifecycleHook, }) { return useDragHandler( mocks?.draggableElementRef ?? ref(document.createElement('div')), - mocks?.dragDomModifier ?? new DragDomModifierMock(), + mocks?.dragDomModifier ?? createDragDomModifierMock(), mocks?.throttler ?? new ThrottleStub().withImmediateExecution(true).func, + mocks?.onTeardown ?? new LifecycleHookStub().getHook(), ); } @@ -248,22 +273,25 @@ function createMockPointerEvent(...args: ConstructorArguments(); - - public addEventListenerToDocument(type: keyof DocumentEventMap, handler: EventListener): void { - this.events.set(type, handler); - } - - public removeEventListenerFromDocument(type: keyof DocumentEventMap): void { - this.events.delete(type); - } - - public simulateEvent(type: keyof DocumentEventMap, event: Event) { - const handler = this.events.get(type); - if (!handler) { - throw new Error(`No event handler registered for: ${type}`); - } - handler(event); - } +function createDragDomModifierMock(): DragDomModifier & { + simulateEvent(type: keyof DocumentEventMap, event: Event): void; + readonly events: Map; +} { + const events = new Map(); + return { + addEventListenerToDocument: (type, handler) => { + events.set(type, handler); + }, + removeEventListenerFromDocument: (type) => { + events.delete(type); + }, + simulateEvent: (type, event) => { + const handler = events.get(type); + if (!handler) { + throw new Error(`No event handler registered for: ${type}`); + } + handler(event); + }, + events, + }; } diff --git a/tests/unit/presentation/components/Scripts/Slider/UseGlobalCursor.spec.ts b/tests/unit/presentation/components/Scripts/Slider/UseGlobalCursor.spec.ts index 457cf3d0..24cf5996 100644 --- a/tests/unit/presentation/components/Scripts/Slider/UseGlobalCursor.spec.ts +++ b/tests/unit/presentation/components/Scripts/Slider/UseGlobalCursor.spec.ts @@ -1,23 +1,25 @@ import { it, describe, expect } from 'vitest'; import { - type Ref, ref, defineComponent, nextTick, + type Ref, ref, nextTick, } from 'vue'; -import { shallowMount } from '@vue/test-utils'; import { type CursorStyleDomModifier, useGlobalCursor } from '@/presentation/components/Scripts/Slider/UseGlobalCursor'; +import type { LifecycleHook } from '@/presentation/components/Shared/Hooks/Common/LifecycleHook'; +import { LifecycleHookStub } from '@tests/unit/shared/Stubs/LifecycleHookStub'; describe('useGlobalCursor', () => { it('adds cursor style to head on activation', async () => { // arrange const expectedCursorCssStyleValue = 'pointer'; const isActive = ref(false); + const domModifier = new CursorStyleDomModifierStub(); // act - const { cursorStyleDomModifierMock } = createHookWithStubs({ isActive }); + createHookWithStubs({ isActive, domModifier }); isActive.value = true; await nextTick(); // assert - const { elementsAppendedToHead: appendedElements } = cursorStyleDomModifierMock; + const { elementsAppendedToHead: appendedElements } = domModifier; expect(appendedElements.length).to.equal(1); expect(appendedElements[0].innerHTML).toContain(expectedCursorCssStyleValue); }); @@ -25,75 +27,61 @@ describe('useGlobalCursor', () => { it('removes cursor style from head on deactivation', async () => { // arrange const isActive = ref(true); + const domModifier = new CursorStyleDomModifierStub(); // act - const { cursorStyleDomModifierMock } = createHookWithStubs({ isActive }); + createHookWithStubs({ isActive, domModifier }); await nextTick(); isActive.value = false; await nextTick(); // assert - expect(cursorStyleDomModifierMock.elementsRemovedFromHead.length).to.equal(1); + expect(domModifier.elementsRemovedFromHead.length).to.equal(1); }); it('cleans up cursor style on unmount', async () => { // arrange const isActive = ref(true); + const domModifier = new CursorStyleDomModifierStub(); + const onTeardown = new LifecycleHookStub(); // act - const { wrapper, returnObject } = mountWrapperComponent({ isActive }); - wrapper.unmount(); + createHookWithStubs({ isActive, domModifier, onTeardown: onTeardown.getHook() }); + onTeardown.executeAllCallbacks(); await nextTick(); // assert - const { cursorStyleDomModifierMock } = returnObject; - expect(cursorStyleDomModifierMock.elementsRemovedFromHead.length).to.equal(1); + expect(onTeardown.totalRegisteredCallbacks).to.be.greaterThan(0); + expect(domModifier.elementsRemovedFromHead.length).to.equal(1); }); it('does not append style to head when initially inactive', async () => { // arrange const isActive = ref(false); + const domModifier = new CursorStyleDomModifierStub(); // act - const { cursorStyleDomModifierMock } = createHookWithStubs({ isActive }); + createHookWithStubs({ isActive, domModifier }); await nextTick(); // assert - expect(cursorStyleDomModifierMock.elementsAppendedToHead.length).toBe(0); + expect(domModifier.elementsAppendedToHead.length).toBe(0); }); }); -function mountWrapperComponent(...hookOptions: Parameters) { - let returnObject: ReturnType | undefined; - const wrapper = shallowMount( - defineComponent({ - setup() { - returnObject = createHookWithStubs(...hookOptions); - }, - template: '
', - }), - ); - if (!returnObject) { - throw new Error('missing hook result'); - } - return { - wrapper, - returnObject, - }; -} - function createHookWithStubs(options?: { readonly isActive?: Ref; readonly expectedCursorCssStyleValue?: string; + readonly domModifier?: CursorStyleDomModifier; + readonly onTeardown?: LifecycleHook; }) { - const cursorStyleDomModifierMock = new CursorStyleDomModifierStub(); const hookResult = useGlobalCursor( options?.isActive ?? ref(true), options?.expectedCursorCssStyleValue ?? 'pointer', - cursorStyleDomModifierMock, + options?.domModifier ?? new CursorStyleDomModifierStub(), + options?.onTeardown ?? new LifecycleHookStub().getHook(), ); return { - cursorStyleDomModifierMock, hookResult, }; } diff --git a/tests/unit/presentation/components/Scripts/View/Tree/TreeView/UseNodeStateChangeAggregator.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/UseNodeStateChangeAggregator.spec.ts index 265f4d67..dc14fe2c 100644 --- a/tests/unit/presentation/components/Scripts/View/Tree/TreeView/UseNodeStateChangeAggregator.spec.ts +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/UseNodeStateChangeAggregator.spec.ts @@ -24,7 +24,7 @@ describe('useNodeStateChangeAggregator', () => { // arrange const expectedTreeRootRef = shallowRef(new TreeRootStub()); const currentTreeNodesStub = new UseCurrentTreeNodesStub(); - const builder = new UseNodeStateChangeAggregatorBuilder() + const builder = new TestContext() .withCurrentTreeNodes(currentTreeNodesStub.get()) .withTreeRootRef(expectedTreeRootRef); // act @@ -61,7 +61,7 @@ describe('useNodeStateChangeAggregator', () => { // arrange const nodesStub = new UseCurrentTreeNodesStub() .withQueryableNodes(createFlatCollection(expectedNodes)); - const { returnObject } = new UseNodeStateChangeAggregatorBuilder() + const { returnObject } = new TestContext() .withCurrentTreeNodes(nodesStub.get()) .mountWrapperComponent(); const { callback, calledArgs } = createSpyingCallback(); @@ -81,7 +81,7 @@ describe('useNodeStateChangeAggregator', () => { it(description, async () => { // arrange const nodesStub = new UseCurrentTreeNodesStub(); - const { returnObject } = new UseNodeStateChangeAggregatorBuilder() + const { returnObject } = new TestContext() .withCurrentTreeNodes(nodesStub.get()) .mountWrapperComponent(); const { callback, calledArgs } = createSpyingCallback(); @@ -104,7 +104,7 @@ describe('useNodeStateChangeAggregator', () => { // arrange const nodesStub = new UseCurrentTreeNodesStub() .withQueryableNodes(createFlatCollection(expectedNodes)); - const { returnObject } = new UseNodeStateChangeAggregatorBuilder() + const { returnObject } = new TestContext() .withCurrentTreeNodes(nodesStub.get()) .mountWrapperComponent(); const { callback, calledArgs } = createSpyingCallback(); @@ -167,7 +167,7 @@ describe('useNodeStateChangeAggregator', () => { .withQueryableNodes(createFlatCollection(initialNodes)); const nodeState = new TreeNodeStateAccessStub(); changedNode.withState(nodeState); - const { returnObject } = new UseNodeStateChangeAggregatorBuilder() + const { returnObject } = new TestContext() .withCurrentTreeNodes(nodesStub.get()) .mountWrapperComponent(); const { callback, calledArgs } = createSpyingCallback(); @@ -216,7 +216,7 @@ describe('useNodeStateChangeAggregator', () => { const nodesStub = new UseCurrentTreeNodesStub() .withQueryableNodes(createFlatCollection(initialNodes)); const eventsStub = new UseAutoUnsubscribedEventsStub(); - const { returnObject } = new UseNodeStateChangeAggregatorBuilder() + const { returnObject } = new TestContext() .withCurrentTreeNodes(nodesStub.get()) .withEventsStub(eventsStub) .mountWrapperComponent(); @@ -290,7 +290,7 @@ function createFlatCollection(nodes: readonly TreeNode[]): QueryableNodesStub { return new QueryableNodesStub().withFlattenedNodes(nodes); } -class UseNodeStateChangeAggregatorBuilder { +class TestContext { private treeRootRef: Readonly> = shallowRef(new TreeRootStub()); private currentTreeNodes: typeof useCurrentTreeNodes = new UseCurrentTreeNodesStub().get(); diff --git a/tests/unit/presentation/components/Shared/Hooks/UseAutoUnsubscribedEventListener.spec.ts b/tests/unit/presentation/components/Shared/Hooks/UseAutoUnsubscribedEventListener.spec.ts index 6508c419..27b1cc43 100644 --- a/tests/unit/presentation/components/Shared/Hooks/UseAutoUnsubscribedEventListener.spec.ts +++ b/tests/unit/presentation/components/Shared/Hooks/UseAutoUnsubscribedEventListener.spec.ts @@ -1,9 +1,9 @@ import { describe, it, expect } from 'vitest'; -import { shallowMount } from '@vue/test-utils'; import { nextTick, shallowRef } from 'vue'; import { useAutoUnsubscribedEventListener } from '@/presentation/components/Shared/Hooks/UseAutoUnsubscribedEventListener'; -import { expectExists } from '@tests/shared/Assertions/ExpectExists'; import { expectDoesNotThrowAsync } from '@tests/shared/Assertions/ExpectThrowsAsync'; +import type { LifecycleHook } from '@/presentation/components/Shared/Hooks/Common/LifecycleHook'; +import { LifecycleHookStub } from '@tests/unit/shared/Stubs/LifecycleHookStub'; describe('UseAutoUnsubscribedEventListener', () => { describe('startListening', () => { @@ -17,9 +17,10 @@ describe('UseAutoUnsubscribedEventListener', () => { const callback = (event: Event) => { actualEvent = event; }; + const context = new TestContext(); // act - const { returnObject } = mountWrapper(); - returnObject.startListening(eventTarget, eventType, callback); + const { startListening } = context.use(); + startListening(eventTarget, eventType, callback); eventTarget.dispatchEvent(expectedEvent); // assert expect(actualEvent).to.equal(expectedEvent); @@ -33,13 +34,16 @@ describe('UseAutoUnsubscribedEventListener', () => { }; const eventTarget = new EventTarget(); const eventType: keyof HTMLElementEventMap = 'abort'; + const teardownHook = new LifecycleHookStub(); + const context = new TestContext() + .withOnTeardown(teardownHook.getHook()); // act - const { wrapper } = mountWrapper({ - setup: (listener) => listener.startListening(eventTarget, eventType, callback), - }); - wrapper.unmount(); + const { startListening } = context.use(); + startListening(eventTarget, eventType, callback); + teardownHook.executeAllCallbacks(); eventTarget.dispatchEvent(new CustomEvent(eventType)); // assert + expect(teardownHook.totalRegisteredCallbacks).to.greaterThan(0); expect(isCallbackCalled).to.equal(expectedCallbackCall); }); }); @@ -54,9 +58,10 @@ describe('UseAutoUnsubscribedEventListener', () => { const callback = (event: Event) => { actualEvent = event; }; + const context = new TestContext(); // act - const { returnObject } = mountWrapper(); - returnObject.startListening(eventTargetRef, eventType, callback); + const { startListening } = context.use(); + startListening(eventTargetRef, eventType, callback); eventTarget.dispatchEvent(expectedEvent); // assert expect(actualEvent).to.equal(expectedEvent); @@ -72,9 +77,10 @@ describe('UseAutoUnsubscribedEventListener', () => { const callback = (event: Event) => { actualEvent = event; }; + const context = new TestContext(); // act - const { returnObject } = mountWrapper(); - returnObject.startListening(targetRef, eventType, callback); + const { startListening } = context.use(); + startListening(targetRef, eventType, callback); targetRef.value = newValue; await nextTick(); newValue.dispatchEvent(expectedEvent); @@ -84,10 +90,11 @@ describe('UseAutoUnsubscribedEventListener', () => { it('does not throw if initial element is undefined', () => { // arrange const targetRef = shallowRef(undefined); + const context = new TestContext(); // act - const { returnObject } = mountWrapper(); + const { startListening } = context.use(); const act = () => { - returnObject.startListening(targetRef, 'abort', () => { /* NO OP */ }); + startListening(targetRef, 'abort', () => { /* NO OP */ }); }; // assert expect(act).to.not.throw(); @@ -95,9 +102,10 @@ describe('UseAutoUnsubscribedEventListener', () => { it('does not throw when reference becomes undefined', async () => { // arrange const targetRef = shallowRef(new EventTarget()); + const context = new TestContext(); // act - const { returnObject } = mountWrapper(); - returnObject.startListening(targetRef, 'abort', () => { /* NO OP */ }); + const { startListening } = context.use(); + startListening(targetRef, 'abort', () => { /* NO OP */ }); const act = async () => { targetRef.value = undefined; await nextTick(); @@ -117,9 +125,10 @@ describe('UseAutoUnsubscribedEventListener', () => { const targetRef = shallowRef(oldValue); const eventType: keyof HTMLElementEventMap = 'abort'; const expectedEvent = new CustomEvent(eventType); + const context = new TestContext(); // act - const { returnObject } = mountWrapper(); - returnObject.startListening(targetRef, eventType, callback); + const { startListening } = context.use(); + startListening(targetRef, eventType, callback); targetRef.value = newValue; await nextTick(); oldValue.dispatchEvent(expectedEvent); @@ -136,11 +145,13 @@ describe('UseAutoUnsubscribedEventListener', () => { const target = new EventTarget(); const targetRef = shallowRef(target); const eventType: keyof HTMLElementEventMap = 'abort'; + const teardownHook = new LifecycleHookStub(); + const context = new TestContext() + .withOnTeardown(teardownHook.getHook()); // act - const { wrapper } = mountWrapper({ - setup: (listener) => listener.startListening(targetRef, eventType, callback), - }); - wrapper.unmount(); + const { startListening } = context.use(); + startListening(targetRef, eventType, callback); + teardownHook.executeAllCallbacks(); target.dispatchEvent(new CustomEvent(eventType)); // assert expect(isCallbackCalled).to.equal(expectedCallbackCall); @@ -149,24 +160,18 @@ describe('UseAutoUnsubscribedEventListener', () => { }); }); -function mountWrapper(options?: { - readonly constructorArgs?: Parameters, - /** Running inside `setup` allows simulating lifecycle events like unmounting. */ - readonly setup?: (returnObject: ReturnType) => void, -}) { - let returnObject: ReturnType | undefined; - const wrapper = shallowMount({ - setup() { - returnObject = useAutoUnsubscribedEventListener(...(options?.constructorArgs ?? [])); - if (options?.setup) { - options.setup(returnObject); - } - }, - template: '
', - }); - expectExists(returnObject); - return { - wrapper, - returnObject, - }; +class TestContext { + private onTeardown: LifecycleHook = new LifecycleHookStub() + .getHook(); + + public withOnTeardown(onTeardown: LifecycleHook): this { + this.onTeardown = onTeardown; + return this; + } + + public use(): ReturnType { + return useAutoUnsubscribedEventListener( + this.onTeardown, + ); + } } diff --git a/tests/unit/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents.spec.ts b/tests/unit/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents.spec.ts index 38b73d01..8b3c53d3 100644 --- a/tests/unit/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents.spec.ts +++ b/tests/unit/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents.spec.ts @@ -1,43 +1,38 @@ import { describe, it, expect } from 'vitest'; -import { shallowMount } from '@vue/test-utils'; import { useAutoUnsubscribedEvents } from '@/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents'; import { EventSubscriptionCollectionStub } from '@tests/unit/shared/Stubs/EventSubscriptionCollectionStub'; import { EventSubscriptionStub } from '@tests/unit/shared/Stubs/EventSubscriptionStub'; import { EventSubscriptionCollection } from '@/infrastructure/Events/EventSubscriptionCollection'; import type { FunctionKeys } from '@/TypeHelpers'; +import type { LifecycleHook } from '@/presentation/components/Shared/Hooks/Common/LifecycleHook'; +import { LifecycleHookStub } from '@tests/unit/shared/Stubs/LifecycleHookStub'; +import type { IEventSubscriptionCollection } from '@/infrastructure/Events/IEventSubscriptionCollection'; describe('UseAutoUnsubscribedEvents', () => { describe('event collection handling', () => { it('returns the provided event collection when initialized', () => { // arrange const expectedEvents = new EventSubscriptionCollectionStub(); + const context = new TestContext() + .withEvents(expectedEvents); // act - const { events: actualEvents } = useAutoUnsubscribedEvents(expectedEvents); + const { events: actualEvents } = context.use(); // assert expect(actualEvents).to.equal(expectedEvents); }); - it('uses a default event collection when none is provided during initialization', () => { - // arrange - const expectedType = EventSubscriptionCollection; - - // act - const { events: actualEvents } = useAutoUnsubscribedEvents(); - - // assert - expect(actualEvents).to.be.instanceOf(expectedType); - }); - it('throws error when there are existing subscriptions', () => { // arrange const expectedError = 'there are existing subscriptions, this may lead to side-effects'; const events = new EventSubscriptionCollectionStub(); events.register([new EventSubscriptionStub(), new EventSubscriptionStub()]); + const context = new TestContext() + .withEvents(events); // act - const act = () => useAutoUnsubscribedEvents(events); + const act = () => context.use(); // assert expect(act).to.throw(expectedError); @@ -48,21 +43,44 @@ describe('UseAutoUnsubscribedEvents', () => { // arrange const events = new EventSubscriptionCollectionStub(); const expectedCall: FunctionKeys = 'unsubscribeAll'; - const stubComponent = shallowMount({ - setup() { - useAutoUnsubscribedEvents(events); - events.register([new EventSubscriptionStub(), new EventSubscriptionStub()]); - }, - template: '
', - }); - events.callHistory.length = 0; + const onTeardown = new LifecycleHookStub(); + const context = new TestContext() + .withEvents(events) + .withOnTeardown(onTeardown.getHook()); // act - stubComponent.unmount(); + context.use(); + events.register([new EventSubscriptionStub(), new EventSubscriptionStub()]); + events.callHistory.length = 0; + onTeardown.executeAllCallbacks(); // assert + expect(onTeardown.totalRegisteredCallbacks).to.be.greaterThan(0); expect(events.callHistory).to.have.lengthOf(1); expect(events.callHistory[0].methodName).to.equal(expectedCall); }); }); }); + +class TestContext { + private onTeardown: LifecycleHook = new LifecycleHookStub().getHook(); + + private events: IEventSubscriptionCollection = new EventSubscriptionCollectionStub(); + + public withOnTeardown(onTeardown: LifecycleHook): this { + this.onTeardown = onTeardown; + return this; + } + + public withEvents(events: IEventSubscriptionCollection): this { + this.events = events; + return this; + } + + public use(): ReturnType { + return useAutoUnsubscribedEvents( + this.events, + this.onTeardown, + ); + } +} diff --git a/tests/unit/presentation/components/Shared/Icon/UseSvgLoader.spec.ts b/tests/unit/presentation/components/Shared/Icon/UseSvgLoader.spec.ts index 6b0fba23..f4b96ebb 100644 --- a/tests/unit/presentation/components/Shared/Icon/UseSvgLoader.spec.ts +++ b/tests/unit/presentation/components/Shared/Icon/UseSvgLoader.spec.ts @@ -4,7 +4,7 @@ import { import { ref } from 'vue'; import type { IconName } from '@/presentation/components/Shared/Icon/IconName'; import { type FileLoaders, clearIconCache, useSvgLoader } from '@/presentation/components/Shared/Icon/UseSvgLoader'; -import { waitForValueChange } from '@tests/shared/WaitForValueChange'; +import { waitForValueChange } from '@tests/shared/Vue/WaitForValueChange'; describe('useSvgLoader', () => { beforeEach(() => { diff --git a/tests/unit/presentation/components/Shared/Modal/Hooks/ScrollLock/UseLockBodyBackgroundScroll.spec.ts b/tests/unit/presentation/components/Shared/Modal/Hooks/ScrollLock/UseLockBodyBackgroundScroll.spec.ts index 213f022d..351c8c4e 100644 --- a/tests/unit/presentation/components/Shared/Modal/Hooks/ScrollLock/UseLockBodyBackgroundScroll.spec.ts +++ b/tests/unit/presentation/components/Shared/Modal/Hooks/ScrollLock/UseLockBodyBackgroundScroll.spec.ts @@ -1,8 +1,8 @@ import { describe, it, expect } from 'vitest'; -import { shallowMount } from '@vue/test-utils'; -import { ref, nextTick, defineComponent } from 'vue'; +import { ref, nextTick } from 'vue'; import { useLockBodyBackgroundScroll } from '@/presentation/components/Shared/Modal/Hooks/ScrollLock/UseLockBodyBackgroundScroll'; import type { ScrollDomStateAccessor } from '@/presentation/components/Shared/Modal/Hooks/ScrollLock/ScrollDomStateAccessor'; +import { executeInComponentSetupContext } from '@tests/shared/Vue/ExecuteInComponentSetupContext'; import { DomStateChangeTestScenarios } from './DomStateChangeTestScenarios'; describe('useLockBodyBackgroundScroll', () => { @@ -13,7 +13,7 @@ describe('useLockBodyBackgroundScroll', () => { const isInitiallyActive = true; // act - createComponent(isInitiallyActive, dom); + mountWrapperComponent(isInitiallyActive, dom); await nextTick(); }); }); @@ -22,7 +22,7 @@ describe('useLockBodyBackgroundScroll', () => { const isInitiallyActive = false; // act - const { initialDomState, actualDomState } = createComponent(isInitiallyActive); + const { initialDomState, actualDomState } = mountWrapperComponent(isInitiallyActive); await nextTick(); // assert @@ -34,7 +34,7 @@ describe('useLockBodyBackgroundScroll', () => { itEachScrollBlockEffect(async (dom) => { // arrange const isInitiallyActive = false; - const { isActive } = createComponent(isInitiallyActive, dom); + const { isActive } = mountWrapperComponent(isInitiallyActive, dom); // act isActive.value = true; @@ -44,7 +44,9 @@ describe('useLockBodyBackgroundScroll', () => { it('reverts to initial styles when deactivated', async () => { // arrange const isInitiallyActive = true; - const { isActive, initialDomState, actualDomState } = createComponent(isInitiallyActive); + const { + isActive, initialDomState, actualDomState, + } = mountWrapperComponent(isInitiallyActive); // act isActive.value = false; @@ -57,7 +59,9 @@ describe('useLockBodyBackgroundScroll', () => { it('restores original styles on unmount', async () => { // arrange const isInitiallyActive = true; - const { component, initialDomState, actualDomState } = createComponent(isInitiallyActive); + const { + component, initialDomState, actualDomState, + } = mountWrapperComponent(isInitiallyActive); // act component.unmount(); @@ -68,19 +72,19 @@ describe('useLockBodyBackgroundScroll', () => { }); }); -function createComponent( +function mountWrapperComponent( initialIsActiveValue: boolean, dom?: ScrollDomStateAccessor, ) { const actualDomState = dom ?? createMockDomStateAccessor(); const initialDomState = { ...actualDomState }; const isActive = ref(initialIsActiveValue); - const component = shallowMount(defineComponent({ - setup() { + const component = executeInComponentSetupContext({ + setupCallback: () => { useLockBodyBackgroundScroll(isActive, actualDomState); }, - template: '
', - })); + disableAutoUnmount: true, + }); return { component, isActive, initialDomState, actualDomState, }; diff --git a/tests/unit/presentation/components/Shared/Modal/ModalContainer.spec.ts b/tests/unit/presentation/components/Shared/Modal/ModalContainer.spec.ts index 019d0826..a65596fe 100644 --- a/tests/unit/presentation/components/Shared/Modal/ModalContainer.spec.ts +++ b/tests/unit/presentation/components/Shared/Modal/ModalContainer.spec.ts @@ -182,7 +182,7 @@ function mountComponent(options: { }, [COMPONENT_MODAL_CONTENT_NAME]: { name: COMPONENT_MODAL_CONTENT_NAME, - template: '', + template: '
', }, }, }, diff --git a/tests/unit/presentation/components/Shared/Resize/UseResizeObserver.spec.ts b/tests/unit/presentation/components/Shared/Resize/UseResizeObserver.spec.ts index c1abcdb4..e1c46f0c 100644 --- a/tests/unit/presentation/components/Shared/Resize/UseResizeObserver.spec.ts +++ b/tests/unit/presentation/components/Shared/Resize/UseResizeObserver.spec.ts @@ -1,9 +1,10 @@ import { shallowRef } from 'vue'; -import { useResizeObserver, type LifecycleHookRegistration, type ObservedElementReference } from '@/presentation/components/Shared/Hooks/Resize/UseResizeObserver'; +import { useResizeObserver, type ObservedElementReference } from '@/presentation/components/Shared/Hooks/Resize/UseResizeObserver'; import { flushPromiseResolutionQueue } from '@tests/unit/shared/PromiseInspection'; import type { AnimationFrameLimiter } from '@/presentation/components/Shared/Hooks/Resize/UseAnimationFrameLimiter'; import { ThrottleStub } from '@tests/unit/shared/Stubs/ThrottleStub'; -import { expectExists } from '@tests/shared/Assertions/ExpectExists'; +import type { LifecycleHook } from '@/presentation/components/Shared/Hooks/Common/LifecycleHook'; +import { LifecycleHookStub } from '@tests/unit/shared/Stubs/LifecycleHookStub'; describe('UseResizeObserver', () => { it('registers observer once mounted', async () => { @@ -30,18 +31,16 @@ describe('UseResizeObserver', () => { resizeObserverStub.disconnect = () => { isObserverDisconnected = true; }; - let teardownCallback: (() => void) | undefined; + const teardownHook = new LifecycleHookStub(); // act new TestContext() .withResizeObserver(resizeObserverStub) - .withOnTeardown((callback) => { - teardownCallback = callback; - }) + .withOnTeardown(teardownHook.getHook()) .useResizeObserver(); await flushPromiseResolutionQueue(); - expectExists(teardownCallback); - teardownCallback(); + teardownHook.executeAllCallbacks(); // assert + expect(teardownHook.totalRegisteredCallbacks).to.be.greaterThan(0); expect(isObserverDisconnected).to.equal(true); }); }); @@ -66,9 +65,12 @@ class TestContext { private observedElementRef: ObservedElementReference = shallowRef(document.createElement('div')); - private onSetup: LifecycleHookRegistration = (callback) => { callback(); }; + private onSetup: LifecycleHook = new LifecycleHookStub() + .withInvokeCallbackImmediately(true) + .getHook(); - private onTeardown: LifecycleHookRegistration = () => { }; + private onTeardown: LifecycleHook = new LifecycleHookStub() + .getHook(); public withResizeObserver(resizeObserver: ResizeObserver): this { this.resizeObserver = resizeObserver; @@ -80,12 +82,12 @@ class TestContext { return this; } - public withOnSetup(onSetup: LifecycleHookRegistration): this { + public withOnSetup(onSetup: LifecycleHook): this { this.onSetup = onSetup; return this; } - public withOnTeardown(onTeardown: LifecycleHookRegistration): this { + public withOnTeardown(onTeardown: LifecycleHook): this { this.onTeardown = onTeardown; return this; } diff --git a/tests/unit/shared/Stubs/LifecycleHookStub.ts b/tests/unit/shared/Stubs/LifecycleHookStub.ts new file mode 100644 index 00000000..87e2d11a --- /dev/null +++ b/tests/unit/shared/Stubs/LifecycleHookStub.ts @@ -0,0 +1,31 @@ +import type { LifecycleHookCallback, LifecycleHook } from '@/presentation/components/Shared/Hooks/Common/LifecycleHook'; + +export class LifecycleHookStub { + private registeredCallbacks = new Array(); + + private invokeCallbackImmediately = false; + + public withInvokeCallbackImmediately(callImmediatelyOnRegistration: boolean): this { + this.invokeCallbackImmediately = callImmediatelyOnRegistration; + return this; + } + + public get totalRegisteredCallbacks(): number { + return this.registeredCallbacks.length; + } + + public executeAllCallbacks() { + for (const callback of this.registeredCallbacks) { + callback(); + } + } + + public getHook(): LifecycleHook { + return (callback: LifecycleHookCallback) => { + this.registeredCallbacks.push(callback); + if (this.invokeCallbackImmediately) { + callback(); + } + }; + } +} diff --git a/tests/unit/shared/Stubs/VueDependencyInjectionApiStub.ts b/tests/unit/shared/Stubs/VueDependencyInjectionApiStub.ts index 49909782..9f71ead6 100644 --- a/tests/unit/shared/Stubs/VueDependencyInjectionApiStub.ts +++ b/tests/unit/shared/Stubs/VueDependencyInjectionApiStub.ts @@ -9,6 +9,10 @@ export class VueDependencyInjectionApiStub implements VueDependencyInjectionApi } public inject(key: InjectionKey): T { - return this.injections.get(key) as T; + const providedValue = this.injections.get(key); + if (providedValue === undefined) { + throw new Error(`[VueDependencyInjectionApiStub] No value provided for key: ${String(key)}`); + } + return providedValue as T; } }