Fix memory leaks via auto-unsubscribing and DI

This commit simplifies event handling, providing a unified and robust
way to handle event lifecycling. This way, it fixes events not being
unsubscribed when state is changed.

Introduce a new function in `EventSubscriptionCollection` to remove
existing events and adding new events. This provides an easier to use
API, which leads to code that's easier to understand. It also prevents
potential bugs that may occur due to forgetting to call both functions.
It fixes `TheScriptsMenu` not unregistering events on state change.
Other improvements include:
  - Include a getter to get total amount of registered subcriptions.
    This helps in unit testing.
  - Have nullish checks to prevent potential errors further down the
    execution.
  - Use array instead of rest parameters to increase readability and
    simplify tests.

Ensure `SliderHandler` stops resizes on unmount, unsubscribing from all
events and resetting state to default.

Update `injectionKeys` to do imports as types to avoid circular
dependencies. Simplify importing `injectionKeys` to enable and strict
typings for iterating injection keys.

Add tests covering new behavior.
This commit is contained in:
undergroundwires
2023-09-01 18:14:25 +02:00
parent 19e42c9c52
commit eb096d07e2
37 changed files with 856 additions and 243 deletions

View File

@@ -14,12 +14,12 @@ The presentation layer uses an event-driven architecture for bidirectional react
- [**`bootstrapping/`**](./../src/presentation/bootstrapping/): Registers Vue components and plugins.
- [**`components/`**](./../src/presentation/components/): Contains Vue components and helpers.
- [**`Shared/`**](./../src/presentation/components/Shared): Contains shared Vue components and helpers.
- [**`hooks`**](../src/presentation/components/Shared/Hooks): Hooks used by components through [dependency injection](#dependency-injections).
- [**`Hooks`**](../src/presentation/components/Shared/Hooks): Hooks used by components through [dependency injection](#dependency-injections).
- [**`/public/`**](../src/presentation/public/): Contains static assets.
- [**`assets/`**](./../src/presentation/assets/styles/): Contains assets processed by Vite.
- [**`fonts/`**](./../src/presentation/assets/fonts/): Contains fonts.
- [**`styles/`**](./../src/presentation/assets/styles/): Contains shared styles.
- [**`components/`**](./../src/presentation/assets/styles/components): Contains styles for Vue components.
- [**`components/`**](./../src/presentation/assets/styles/components): Contains styles coupled to Vue components.
- [**`vendors-extensions/`**](./../src/presentation/assets/styles/third-party-extensions): Contains styles for third-party components.
- [**`main.scss`**](./../src/presentation/assets/styles/main.scss): Main Sass file, imported by other components as single entrypoint.
- [**`main.ts`**](./../src/presentation/main.ts): Starts Vue app.

View File

@@ -1,10 +1,20 @@
import { IEventSubscription } from './IEventSource';
import { IEventSubscriptionCollection } from './IEventSubscriptionCollection';
import { IEventSubscription } from './IEventSource';
export class EventSubscriptionCollection implements IEventSubscriptionCollection {
private readonly subscriptions = new Array<IEventSubscription>();
public register(...subscriptions: IEventSubscription[]) {
public get subscriptionCount() {
return this.subscriptions.length;
}
public register(subscriptions: IEventSubscription[]) {
if (!subscriptions || subscriptions.length === 0) {
throw new Error('missing subscriptions');
}
if (subscriptions.some((subscription) => !subscription)) {
throw new Error('missing subscription in list');
}
this.subscriptions.push(...subscriptions);
}
@@ -12,4 +22,9 @@ export class EventSubscriptionCollection implements IEventSubscriptionCollection
this.subscriptions.forEach((listener) => listener.unsubscribe());
this.subscriptions.splice(0, this.subscriptions.length);
}
public unsubscribeAllAndRegister(subscriptions: IEventSubscription[]) {
this.unsubscribeAll();
this.register(subscriptions);
}
}

View File

@@ -1,7 +1,9 @@
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
export interface IEventSubscriptionCollection {
register(...subscriptions: IEventSubscription[]);
readonly subscriptionCount: number;
unsubscribeAll();
register(subscriptions: IEventSubscription[]): void;
unsubscribeAll(): void;
unsubscribeAllAndRegister(subscriptions: IEventSubscription[]);
}

View File

@@ -1,28 +1,31 @@
import { InjectionKey, provide } from 'vue';
import { InjectionKey, provide, inject } from 'vue';
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
import {
useCollectionStateKey, useApplicationKey, useRuntimeEnvironmentKey,
} from '@/presentation/injectionSymbols';
import { useAutoUnsubscribedEvents } from '@/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents';
import { IApplicationContext } from '@/application/Context/IApplicationContext';
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import { InjectionKeys } from '@/presentation/injectionSymbols';
export function provideDependencies(context: IApplicationContext) {
registerSingleton(useApplicationKey, useApplication(context.app));
registerTransient(useCollectionStateKey, () => useCollectionState(context));
registerSingleton(useRuntimeEnvironmentKey, RuntimeEnvironment.CurrentEnvironment);
}
function registerSingleton<T>(
key: InjectionKey<T>,
value: T,
export function provideDependencies(
context: IApplicationContext,
api: VueDependencyInjectionApi = { provide, inject },
) {
provide(key, value);
}
function registerTransient<T>(
const registerSingleton = <T>(key: InjectionKey<T>, value: T) => api.provide(key, value);
const registerTransient = <T>(
key: InjectionKey<() => T>,
factory: () => T,
) {
provide(key, factory);
) => api.provide(key, factory);
registerSingleton(InjectionKeys.useApplication, useApplication(context.app));
registerSingleton(InjectionKeys.useRuntimeEnvironment, RuntimeEnvironment.CurrentEnvironment);
registerTransient(InjectionKeys.useAutoUnsubscribedEvents, () => useAutoUnsubscribedEvents());
registerTransient(InjectionKeys.useCollectionState, () => {
const { events } = api.inject(InjectionKeys.useAutoUnsubscribedEvents)();
return useCollectionState(context, events);
});
}
export interface VueDependencyInjectionApi {
provide<T>(key: InjectionKey<T>, value: T): void;
inject<T>(key: InjectionKey<T>): T;
}

View File

@@ -59,7 +59,7 @@ import {
defineComponent, PropType, computed,
inject,
} from 'vue';
import { useApplicationKey } from '@/presentation/injectionSymbols';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { OperatingSystem } from '@/domain/OperatingSystem';
import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
import CodeInstruction from './CodeInstruction.vue';
@@ -77,7 +77,7 @@ export default defineComponent({
},
},
setup(props) {
const { info } = inject(useApplicationKey);
const { info } = inject(InjectionKeys.useApplication);
const appName = computed<string>(() => info.name);

View File

@@ -29,7 +29,7 @@
import {
defineComponent, ref, computed, inject,
} from 'vue';
import { useCollectionStateKey, useRuntimeEnvironmentKey } from '@/presentation/injectionSymbols';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog';
import { Clipboard } from '@/infrastructure/Clipboard';
import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
@@ -53,9 +53,10 @@ export default defineComponent({
},
setup() {
const {
currentState, currentContext, onStateChange, events,
} = inject(useCollectionStateKey)();
const { os, isDesktop } = inject(useRuntimeEnvironmentKey);
currentState, currentContext, onStateChange,
} = inject(InjectionKeys.useCollectionState)();
const { os, isDesktop } = inject(InjectionKeys.useRuntimeEnvironment);
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const areInstructionsVisible = ref(false);
const canRun = computed<boolean>(() => getCanRunState(currentState.value.os, isDesktop, os));
@@ -81,15 +82,18 @@ export default defineComponent({
}
onStateChange((newState) => {
updateCurrentCode(newState.code.current);
subscribeToCodeChanges(newState.code);
}, { immediate: true });
function subscribeToCodeChanges(code: IApplicationCode) {
hasCode.value = code.current && code.current.length > 0;
events.unsubscribeAll();
events.register(code.changed.on((newCode) => {
hasCode.value = newCode && newCode.code.length > 0;
}));
events.unsubscribeAllAndRegister([
code.changed.on((newCode) => updateCurrentCode(newCode.code)),
]);
}
function updateCurrentCode(code: string) {
hasCode.value = code && code.length > 0;
}
async function getCurrentCode(): Promise<IApplicationCode> {

View File

@@ -13,7 +13,7 @@
import {
defineComponent, onUnmounted, onMounted, inject,
} from 'vue';
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { ICodeChangedEvent } from '@/application/Context/State/Code/Event/ICodeChangedEvent';
import { IScript } from '@/domain/IScript';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
@@ -37,7 +37,8 @@ export default defineComponent({
NonCollapsing,
},
setup(props) {
const { onStateChange, currentState, events } = inject(useCollectionStateKey)();
const { onStateChange, currentState } = inject(InjectionKeys.useCollectionState)();
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const editorId = 'codeEditor';
let editor: ace.Ace.Editor | undefined;
@@ -61,19 +62,20 @@ export default defineComponent({
newState.collection.scripting.language,
);
const appCode = newState.code;
const innerCode = appCode.current || getDefaultCode(newState.collection.scripting.language);
editor.setValue(innerCode, 1);
events.unsubscribeAll();
events.register(appCode.changed.on((code) => updateCode(code)));
updateCode(appCode.current, newState.collection.scripting.language);
events.unsubscribeAllAndRegister([
appCode.changed.on((code) => handleCodeChange(code)),
]);
}
function updateCode(event: ICodeChangedEvent) {
removeCurrentHighlighting();
if (event.isEmpty()) {
const defaultCode = getDefaultCode(currentState.value.collection.scripting.language);
editor.setValue(defaultCode, 1);
return;
function updateCode(code: string, language: ScriptingLanguage) {
const innerCode = code || getDefaultCode(language);
editor.setValue(innerCode, 1);
}
function handleCodeChange(event: ICodeChangedEvent) {
removeCurrentHighlighting();
updateCode(event.code, currentState.value.collection.scripting.language);
editor.setValue(event.code, 1);
if (event.addedScripts?.length > 0) {
reactToChanges(event, event.addedScripts);

View File

@@ -66,9 +66,10 @@
<script lang="ts">
import { defineComponent, ref, inject } from 'vue';
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
import MenuOptionList from '../MenuOptionList.vue';
import MenuOptionListItem from '../MenuOptionListItem.vue';
import { SelectionType, SelectionTypeHandler } from './SelectionTypeHandler';
@@ -80,28 +81,27 @@ export default defineComponent({
TooltipWrapper,
},
setup() {
const { modifyCurrentState, onStateChange, events } = inject(useCollectionStateKey)();
const { modifyCurrentState, onStateChange } = inject(InjectionKeys.useCollectionState)();
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const currentSelection = ref(SelectionType.None);
let selectionTypeHandler: SelectionTypeHandler;
onStateChange(() => {
unregisterMutators();
modifyCurrentState((state) => {
registerStateMutator(state);
selectionTypeHandler = new SelectionTypeHandler(state);
updateSelections();
events.unsubscribeAllAndRegister([
subscribeAndUpdateSelections(state),
]);
});
}, { immediate: true });
function unregisterMutators() {
events.unsubscribeAll();
}
function registerStateMutator(state: ICategoryCollectionState) {
selectionTypeHandler = new SelectionTypeHandler(state);
updateSelections();
events.register(state.selection.changed.on(() => updateSelections()));
function subscribeAndUpdateSelections(
state: ICategoryCollectionState,
): IEventSubscription {
return state.selection.changed.on(() => updateSelections());
}
function selectType(type: SelectionType) {

View File

@@ -14,7 +14,7 @@
import {
defineComponent, computed, inject,
} from 'vue';
import { useApplicationKey, useCollectionStateKey } from '@/presentation/injectionSymbols';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { OperatingSystem } from '@/domain/OperatingSystem';
import MenuOptionList from './MenuOptionList.vue';
import MenuOptionListItem from './MenuOptionListItem.vue';
@@ -30,8 +30,8 @@ export default defineComponent({
MenuOptionListItem,
},
setup() {
const { modifyCurrentContext, currentState } = inject(useCollectionStateKey)();
const { application } = inject(useApplicationKey);
const { modifyCurrentContext, currentState } = inject(InjectionKeys.useCollectionState)();
const { application } = inject(InjectionKeys.useApplication);
const allOses = computed<ReadonlyArray<IOsViewModel>>(() => (
application.getSupportedOsList() ?? [])

View File

@@ -11,10 +11,11 @@
<script lang="ts">
import {
defineComponent, ref, onUnmounted, inject,
defineComponent, ref, inject,
} from 'vue';
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter';
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
import TheOsChanger from './TheOsChanger.vue';
import TheSelector from './Selector/TheSelector.vue';
import TheViewChanger from './View/TheViewChanger.vue';
@@ -26,31 +27,26 @@ export default defineComponent({
TheViewChanger,
},
setup() {
const { onStateChange, events } = inject(useCollectionStateKey)();
const { onStateChange } = inject(InjectionKeys.useCollectionState)();
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const isSearching = ref(false);
onStateChange((state) => {
subscribeToFilterChanges(state.filter);
events.unsubscribeAllAndRegister([
subscribeToFilterChanges(state.filter),
]);
}, { immediate: true });
onUnmounted(() => {
unsubscribeAll();
});
function subscribeToFilterChanges(filter: IReadOnlyUserFilter) {
events.register(
filter.filterChanged.on((event) => {
function subscribeToFilterChanges(
filter: IReadOnlyUserFilter,
): IEventSubscription {
return filter.filterChanged.on((event) => {
event.visit({
onApply: () => { isSearching.value = true; },
onClear: () => { isSearching.value = false; },
});
}),
);
}
function unsubscribeAll() {
events.unsubscribeAll();
});
}
return {

View File

@@ -13,7 +13,7 @@
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { defineComponent, onUnmounted } from 'vue';
export default defineComponent({
emits: {
@@ -46,6 +46,10 @@ export default defineComponent({
event.preventDefault();
}
onUnmounted(() => {
stopResize();
});
return {
cursorCssValue,
startResize,

View File

@@ -37,7 +37,7 @@ import {
defineComponent, ref, onMounted, onUnmounted, computed,
inject,
} from 'vue';
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import SizeObserver from '@/presentation/components/Shared/SizeObserver.vue';
import { hasDirective } from './NonCollapsingDirective';
import CardListItem from './CardListItem.vue';
@@ -48,7 +48,7 @@ export default defineComponent({
SizeObserver,
},
setup() {
const { currentState, onStateChange } = inject(useCollectionStateKey)();
const { currentState, onStateChange } = inject(InjectionKeys.useCollectionState)();
const width = ref<number>(0);
const categoryIds = computed<ReadonlyArray<number>>(() => currentState

View File

@@ -52,7 +52,7 @@ import {
defineComponent, ref, watch, computed,
inject,
} from 'vue';
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import ScriptsTree from '@/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue';
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
@@ -76,7 +76,8 @@ export default defineComponent({
/* eslint-enable @typescript-eslint/no-unused-vars */
},
setup(props, { emit }) {
const { events, onStateChange, currentState } = inject(useCollectionStateKey)();
const { onStateChange, currentState } = inject(InjectionKeys.useCollectionState)();
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const isExpanded = computed({
get: () => {
@@ -106,12 +107,13 @@ export default defineComponent({
isExpanded.value = false;
}
onStateChange(async (state) => {
events.unsubscribeAll();
events.register(state.selection.changed.on(
onStateChange((state) => {
events.unsubscribeAllAndRegister([
state.selection.changed.on(
() => updateSelectionIndicators(props.categoryId),
));
await updateSelectionIndicators(props.categoryId);
),
]);
updateSelectionIndicators(props.categoryId);
}, { immediate: true });
watch(
@@ -124,7 +126,7 @@ export default defineComponent({
cardElement.value.scrollIntoView({ behavior: 'smooth' });
}
async function updateSelectionIndicators(categoryId: number) {
function updateSelectionIndicators(categoryId: number) {
const category = currentState.value.collection.findCategory(categoryId);
const { selection } = currentState.value;
isAnyChildSelected.value = category ? selection.isAnySelected(category) : false;

View File

@@ -17,12 +17,13 @@
import {
defineComponent, watch, ref, inject,
} from 'vue';
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { IScript } from '@/domain/IScript';
import { ICategory } from '@/domain/ICategory';
import { ICategoryCollectionState, IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
import {
parseAllCategories, parseSingleCategory, getScriptNodeId, getCategoryNodeId, getCategoryId,
getScriptId,
@@ -43,8 +44,9 @@ export default defineComponent({
},
setup(props) {
const {
modifyCurrentState, currentState, onStateChange, events,
} = inject(useCollectionStateKey)();
modifyCurrentState, currentState, onStateChange,
} = inject(InjectionKeys.useCollectionState)();
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const nodes = ref<ReadonlyArray<INodeContent>>([]);
const selectedNodeIds = ref<ReadonlyArray<string>>([]);
@@ -54,7 +56,7 @@ export default defineComponent({
watch(
() => props.categoryId,
async (newCategoryId) => { await setNodes(newCategoryId); },
(newCategoryId) => setNodes(newCategoryId),
{ immediate: true },
);
@@ -63,8 +65,7 @@ export default defineComponent({
if (!props.categoryId) {
nodes.value = parseAllCategories(state.collection);
}
events.unsubscribeAll();
subscribeToState(state);
events.unsubscribeAllAndRegister(subscribeToState(state));
}, { immediate: true });
function toggleNodeSelection(event: INodeSelectedEvent) {
@@ -87,7 +88,7 @@ export default defineComponent({
|| containsCategory(node, filtered.categoryMatches);
}
async function setNodes(categoryId?: number) {
function setNodes(categoryId?: number) {
if (categoryId) {
nodes.value = parseSingleCategory(categoryId, currentState.value.collection);
} else {
@@ -97,8 +98,10 @@ export default defineComponent({
.map((selected) => getScriptNodeId(selected.script));
}
function subscribeToState(state: IReadOnlyCategoryCollectionState) {
events.register(
function subscribeToState(
state: IReadOnlyCategoryCollectionState,
): IEventSubscription[] {
return [
state.selection.changed.on((scripts) => handleSelectionChanged(scripts)),
state.filter.filterChanged.on((event) => {
event.visit({
@@ -111,7 +114,7 @@ export default defineComponent({
},
});
}),
);
];
}
function setCurrentFilter(currentFilter: IFilterResult | undefined) {

View File

@@ -12,7 +12,7 @@ import {
computed, inject,
} from 'vue';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { IReverter } from './Reverter/IReverter';
import { INodeContent } from './INodeContent';
import { getReverter } from './Reverter/ReverterFactory';
@@ -30,8 +30,9 @@ export default defineComponent({
},
setup(props) {
const {
currentState, modifyCurrentState, onStateChange, events,
} = inject(useCollectionStateKey)();
currentState, modifyCurrentState, onStateChange,
} = inject(InjectionKeys.useCollectionState)();
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const isReverted = ref(false);
@@ -39,24 +40,23 @@ export default defineComponent({
watch(
() => props.node,
async (node) => { await onNodeChanged(node); },
(node) => onNodeChanged(node),
{ immediate: true },
);
onStateChange((newState) => {
updateRevertStatusFromState(newState.selection.selectedScripts);
events.unsubscribeAll();
events.register(
events.unsubscribeAllAndRegister([
newState.selection.changed.on((scripts) => updateRevertStatusFromState(scripts)),
);
]);
}, { immediate: true });
async function onNodeChanged(node: INodeContent) {
function onNodeChanged(node: INodeContent) {
handler = getReverter(node, currentState.value.collection);
updateRevertStatusFromState(currentState.value.selection.selectedScripts);
}
async function updateRevertStatusFromState(scripts: ReadonlyArray<SelectedScript>) {
function updateRevertStatusFromState(scripts: ReadonlyArray<SelectedScript>) {
isReverted.value = handler?.getState(scripts) ?? false;
}

View File

@@ -41,7 +41,7 @@ import {
defineComponent, PropType, ref, computed,
inject,
} from 'vue';
import { useApplicationKey, useCollectionStateKey } from '@/presentation/injectionSymbols';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import ScriptsTree from '@/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue';
import CardList from '@/presentation/components/Scripts/View/Cards/CardList.vue';
import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType';
@@ -60,8 +60,9 @@ export default defineComponent({
},
},
setup() {
const { modifyCurrentState, onStateChange, events } = inject(useCollectionStateKey)();
const { info } = inject(useApplicationKey);
const { modifyCurrentState, onStateChange } = inject(InjectionKeys.useCollectionState)();
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const { info } = inject(InjectionKeys.useApplication);
const repositoryUrl = computed<string>(() => info.repositoryWebUrl);
const searchQuery = ref<string | undefined>();
@@ -77,9 +78,10 @@ export default defineComponent({
});
onStateChange((newState) => {
events.unsubscribeAll();
updateFromInitialFilter(newState.filter.currentFilter);
subscribeToFilterChanges(newState.filter);
events.unsubscribeAllAndRegister([
subscribeToFilterChanges(newState.filter),
]);
}, { immediate: true });
function clearSearchQuery() {
@@ -95,8 +97,7 @@ export default defineComponent({
}
function subscribeToFilterChanges(filter: IReadOnlyUserFilter) {
events.register(
filter.filterChanged.on((event) => {
return filter.filterChanged.on((event) => {
event.visit({
onApply: (newFilter) => {
searchQuery.value = newFilter.query;
@@ -106,8 +107,7 @@ export default defineComponent({
searchQuery.value = undefined;
},
});
}),
);
});
}
return {

View File

@@ -0,0 +1,19 @@
import { onUnmounted } from 'vue';
import { EventSubscriptionCollection } from '@/infrastructure/Events/EventSubscriptionCollection';
import { IEventSubscriptionCollection } from '@/infrastructure/Events/IEventSubscriptionCollection';
export function useAutoUnsubscribedEvents(
events: IEventSubscriptionCollection = new EventSubscriptionCollection(),
) {
if (events.subscriptionCount > 0) {
throw new Error('there are existing subscriptions, this may lead to side-effects');
}
onUnmounted(() => {
events.unsubscribeAll();
});
return {
events,
};
}

View File

@@ -1,23 +1,27 @@
import { ref, computed, readonly } from 'vue';
import {
ref, computed, readonly,
} from 'vue';
import { IApplicationContext, IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
import { ICategoryCollectionState, IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { EventSubscriptionCollection } from '@/infrastructure/Events/EventSubscriptionCollection';
import { IEventSubscriptionCollection } from '@/infrastructure/Events/IEventSubscriptionCollection';
export function useCollectionState(context: IApplicationContext) {
export function useCollectionState(
context: IApplicationContext,
events: IEventSubscriptionCollection,
) {
if (!context) {
throw new Error('missing context');
}
const events = new EventSubscriptionCollection();
const ownEvents = new EventSubscriptionCollection();
if (!events) {
throw new Error('missing events');
}
const currentState = ref<ICategoryCollectionState>(context.state);
ownEvents.register(
events.register([
context.contextChanged.on((event) => {
currentState.value = event.newState;
}),
);
]);
const defaultSettings: IStateCallbackSettings = {
immediate: false,
@@ -29,11 +33,11 @@ export function useCollectionState(context: IApplicationContext) {
if (!handler) {
throw new Error('missing state handler');
}
ownEvents.register(
events.register([
context.contextChanged.on((event) => {
handler(event.newState, event.oldState);
}),
);
]);
const defaultedSettings: IStateCallbackSettings = {
...defaultSettings,
...settings,

View File

@@ -20,7 +20,7 @@
<script lang="ts">
import { defineComponent, inject } from 'vue';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { useRuntimeEnvironmentKey } from '@/presentation/injectionSymbols';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import DownloadUrlListItem from './DownloadUrlListItem.vue';
const supportedOperativeSystems: readonly OperatingSystem[] = [
@@ -34,7 +34,7 @@ export default defineComponent({
DownloadUrlListItem,
},
setup() {
const { os: currentOs } = inject(useRuntimeEnvironmentKey);
const { os: currentOs } = inject(InjectionKeys.useRuntimeEnvironment);
const supportedDesktops = [
...supportedOperativeSystems,
].sort((os) => (os === currentOs ? 0 : 1));

View File

@@ -14,7 +14,7 @@ import {
defineComponent, PropType, computed,
inject,
} from 'vue';
import { useApplicationKey, useRuntimeEnvironmentKey } from '@/presentation/injectionSymbols';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { OperatingSystem } from '@/domain/OperatingSystem';
export default defineComponent({
@@ -25,8 +25,8 @@ export default defineComponent({
},
},
setup(props) {
const { info } = inject(useApplicationKey);
const { os: currentOs } = inject(useRuntimeEnvironmentKey);
const { info } = inject(InjectionKeys.useApplication);
const { os: currentOs } = inject(InjectionKeys.useRuntimeEnvironment);
const isCurrentOs = computed<boolean>(() => {
return currentOs === props.operatingSystem;

View File

@@ -42,12 +42,12 @@
<script lang="ts">
import { defineComponent, computed, inject } from 'vue';
import { useApplicationKey, useRuntimeEnvironmentKey } from '@/presentation/injectionSymbols';
import { InjectionKeys } from '@/presentation/injectionSymbols';
export default defineComponent({
setup() {
const { info } = inject(useApplicationKey);
const { isDesktop } = inject(useRuntimeEnvironmentKey);
const { info } = inject(InjectionKeys.useApplication);
const { isDesktop } = inject(InjectionKeys.useRuntimeEnvironment);
const repositoryUrl = computed<string>(() => info.repositoryUrl);
const feedbackUrl = computed<string>(() => info.feedbackUrl);

View File

@@ -48,7 +48,7 @@ import {
defineComponent, ref, computed, inject,
} from 'vue';
import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
import { useApplicationKey, useRuntimeEnvironmentKey } from '@/presentation/injectionSymbols';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import DownloadUrlList from './DownloadUrlList.vue';
import PrivacyPolicy from './PrivacyPolicy.vue';
@@ -59,8 +59,8 @@ export default defineComponent({
DownloadUrlList,
},
setup() {
const { info } = inject(useApplicationKey);
const { isDesktop } = inject(useRuntimeEnvironmentKey);
const { info } = inject(InjectionKeys.useApplication);
const { isDesktop } = inject(InjectionKeys.useRuntimeEnvironment);
const isPrivacyDialogVisible = ref(false);

View File

@@ -7,11 +7,11 @@
<script lang="ts">
import { defineComponent, computed, inject } from 'vue';
import { useApplicationKey } from '@/presentation/injectionSymbols';
import { InjectionKeys } from '@/presentation/injectionSymbols';
export default defineComponent({
setup() {
const { info } = inject(useApplicationKey);
const { info } = inject(InjectionKeys.useApplication);
const title = computed(() => info.name);
const subtitle = computed(() => info.slogan);

View File

@@ -17,10 +17,11 @@ import {
defineComponent, ref, watch, computed,
inject,
} from 'vue';
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { NonCollapsing } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective';
import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter';
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
export default defineComponent({
directives: {
@@ -28,8 +29,9 @@ export default defineComponent({
},
setup() {
const {
modifyCurrentState, onStateChange, events, currentState,
} = inject(useCollectionStateKey)();
modifyCurrentState, onStateChange, currentState,
} = inject(InjectionKeys.useCollectionState)();
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const searchPlaceholder = computed<string>(() => {
const { totalScripts } = currentState.value.collection;
@@ -51,18 +53,20 @@ export default defineComponent({
}
onStateChange((newState) => {
events.unsubscribeAll();
updateFromInitialFilter(newState.filter.currentFilter);
subscribeToFilterChanges(newState.filter);
events.unsubscribeAllAndRegister([
subscribeToFilterChanges(newState.filter),
]);
}, { immediate: true });
function updateFromInitialFilter(filter?: IFilterResult) {
searchQuery.value = filter?.query || '';
}
function subscribeToFilterChanges(filter: IReadOnlyUserFilter) {
events.register(
filter.filterChanged.on((event) => {
function subscribeToFilterChanges(
filter: IReadOnlyUserFilter,
): IEventSubscription {
return filter.filterChanged.on((event) => {
event.visit({
onApply: (result) => {
searchQuery.value = result.query;
@@ -71,8 +75,7 @@ export default defineComponent({
searchQuery.value = '';
},
});
}),
);
});
}
return {

View File

@@ -1,16 +1,20 @@
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
import { useRuntimeEnvironment } from '@/presentation/components/Shared/Hooks/UseRuntimeEnvironment';
import type { useAutoUnsubscribedEvents } from './components/Shared/Hooks/UseAutoUnsubscribedEvents';
import type { InjectionKey } from 'vue';
export const useCollectionStateKey = defineTransientKey<ReturnType<typeof useCollectionState>>('useCollectionState');
export const useApplicationKey = defineSingletonKey<ReturnType<typeof useApplication>>('useApplication');
export const useRuntimeEnvironmentKey = defineSingletonKey<ReturnType<typeof useRuntimeEnvironment>>('useRuntimeEnvironment');
export const InjectionKeys = {
useCollectionState: defineTransientKey<ReturnType<typeof useCollectionState>>('useCollectionState'),
useApplication: defineSingletonKey<ReturnType<typeof useApplication>>('useApplication'),
useRuntimeEnvironment: defineSingletonKey<ReturnType<typeof useRuntimeEnvironment>>('useRuntimeEnvironment'),
useAutoUnsubscribedEvents: defineTransientKey<ReturnType<typeof useAutoUnsubscribedEvents>>('useAutoUnsubscribedEvents'),
};
function defineSingletonKey<T>(key: string) {
function defineSingletonKey<T>(key: string): InjectionKey<T> {
return Symbol(key) as InjectionKey<T>;
}
function defineTransientKey<T>(key: string) {
function defineTransientKey<T>(key: string): InjectionKey<() => T> {
return Symbol(key) as InjectionKey<() => T>;
}

View File

@@ -1,21 +1,172 @@
import { describe, it, expect } from 'vitest';
import { EventSubscriptionCollection } from '@/infrastructure/Events/EventSubscriptionCollection';
import { EventSubscriptionStub } from '@tests/unit/shared/Stubs/EventSubscriptionStub';
import { itEachAbsentCollectionValue, itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
describe('EventSubscriptionCollection', () => {
it('unsubscribeAll unsubscribes from all registered subscriptions', () => {
describe('register', () => {
it('increments `subscriptionCount` for each registration', () => {
// arrange
const sut = new EventSubscriptionCollection();
const expected = ['unsubscribed1', 'unsubscribed2'];
const actual = new Array<string>();
const subscriptions: IEventSubscription[] = [
{ unsubscribe: () => actual.push(expected[0]) },
{ unsubscribe: () => actual.push(expected[1]) },
];
const subscriptions = createSubscriptionStubList(2);
// act
sut.register(...subscriptions);
sut.unsubscribeAll();
sut.register(subscriptions);
// assert
expect(actual).to.deep.equal(expected);
expect(sut.subscriptionCount).to.equal(2);
});
it('retains the subscribed state of registrations', () => {
// arrange
const sut = new EventSubscriptionCollection();
const subscriptions = createSubscriptionStubList(2);
// act
sut.register(subscriptions);
// assert
expectAllSubscribed(subscriptions);
});
describe('validation', () => {
// arrange
const sut = new EventSubscriptionCollection();
// act
const act = (
subscriptions: IEventSubscription[],
) => sut.register(subscriptions);
/// assert
describeSubscriptionValidations(act);
});
});
describe('unsubscribeAll', () => {
it('unsubscribes all registrations', () => {
// arrange
const sut = new EventSubscriptionCollection();
const subscriptions = createSubscriptionStubList(2);
// act
sut.register(subscriptions);
sut.unsubscribeAll();
// assert
expectAllUnsubscribed(subscriptions);
});
it('resets `subscriptionCount` to zero', () => {
// arrange
const sut = new EventSubscriptionCollection();
const subscriptions = createSubscriptionStubList(2);
sut.register(subscriptions);
// act
sut.unsubscribeAll();
// assert
expect(sut.subscriptionCount).to.equal(0);
});
});
describe('unsubscribeAllAndRegister', () => {
it('unsubscribes all previous registrations', () => {
// arrange
const sut = new EventSubscriptionCollection();
const oldSubscriptions = createSubscriptionStubList(2);
sut.register(oldSubscriptions);
const newSubscriptions = createSubscriptionStubList(1);
// act
sut.unsubscribeAllAndRegister(newSubscriptions);
// assert
expectAllUnsubscribed(oldSubscriptions);
});
it('retains the subscribed state of new registrations', () => {
// arrange
const sut = new EventSubscriptionCollection();
const oldSubscriptions = createSubscriptionStubList(2);
sut.register(oldSubscriptions);
const newSubscriptions = createSubscriptionStubList(2);
// act
sut.unsubscribeAllAndRegister(newSubscriptions);
// assert
expectAllSubscribed(newSubscriptions);
});
it('updates `subscriptionCount` to match new registration count', () => {
// arrange
const sut = new EventSubscriptionCollection();
const initialSubscriptionAmount = 1;
const expectedSubscriptionAmount = 3;
const oldSubscriptions = createSubscriptionStubList(initialSubscriptionAmount);
sut.register(oldSubscriptions);
const newSubscriptions = createSubscriptionStubList(expectedSubscriptionAmount);
// act
sut.unsubscribeAllAndRegister(newSubscriptions);
// assert
expect(sut.subscriptionCount).to.equal(expectedSubscriptionAmount);
});
describe('validation', () => {
// arrange
const sut = new EventSubscriptionCollection();
// act
const act = (
subscriptions: IEventSubscription[],
) => sut.unsubscribeAllAndRegister(subscriptions);
/// assert
describeSubscriptionValidations(act);
});
});
});
function expectAllSubscribed(subscriptions: EventSubscriptionStub[]) {
expect(subscriptions.every((subscription) => subscription.isSubscribed)).to.equal(true);
}
function expectAllUnsubscribed(subscriptions: EventSubscriptionStub[]) {
expect(subscriptions.every((subscription) => subscription.isUnsubscribed)).to.equal(true);
}
function createSubscriptionStubList(amount: number): EventSubscriptionStub[] {
if (amount <= 0) {
throw new Error(`unexpected amount of subscriptions: ${amount}`);
}
return Array.from({ length: amount }, () => new EventSubscriptionStub());
}
function describeSubscriptionValidations(
handleValue: (subscriptions: IEventSubscription[]) => void,
) {
describe('throws error if no subscriptions are provided', () => {
itEachAbsentCollectionValue((absentValue) => {
// arrange
const expectedError = 'missing subscriptions';
// act
const act = () => handleValue(absentValue);
// assert
expect(act).to.throw(expectedError);
});
});
describe('throws error if nullish subscriptions are provided', () => {
itEachAbsentObjectValue((absentValue) => {
// arrange
const expectedError = 'missing subscription in list';
const subscriptions = [
new EventSubscriptionStub(),
absentValue,
new EventSubscriptionStub(),
];
// act
const act = () => handleValue(subscriptions);
// assert
expect(act).to.throw(expectedError);
});
});
}

View File

@@ -0,0 +1,99 @@
import { describe } from 'vitest';
import { VueDependencyInjectionApiStub } from '@tests/unit/shared/Stubs/VueDependencyInjectionApiStub';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { provideDependencies, VueDependencyInjectionApi } from '@/presentation/bootstrapping/DependencyProvider';
import { ApplicationContextStub } from '@tests/unit/shared/Stubs/ApplicationContextStub';
import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests';
describe('DependencyProvider', () => {
describe('provideDependencies', () => {
const testCases: {
readonly [K in keyof typeof InjectionKeys]: (injectionKey: symbol) => void;
} = {
useCollectionState: createTransientTests(),
useApplication: createSingletonTests(),
useRuntimeEnvironment: createSingletonTests(),
useAutoUnsubscribedEvents: createTransientTests(),
};
Object.entries(testCases).forEach(([key, runTests]) => {
describe(`Key: "${key}"`, () => {
runTests(InjectionKeys[key]);
});
});
});
});
function createTransientTests() {
return (injectionKey: symbol) => {
it('should register a function when transient dependency is resolved', () => {
// arrange
const api = new VueDependencyInjectionApiStub();
// act
new ProvideDependenciesBuilder()
.withApi(api)
.provideDependencies();
// expect
const registeredObject = api.inject(injectionKey);
expect(registeredObject).to.be.instanceOf(Function);
});
it('should return different instances for transient dependency', () => {
// arrange
const api = new VueDependencyInjectionApiStub();
// act
new ProvideDependenciesBuilder()
.withApi(api)
.provideDependencies();
// expect
const registeredObject = api.inject(injectionKey);
const factory = registeredObject as () => unknown;
const firstResult = factory();
const secondResult = factory();
expect(firstResult).to.not.equal(secondResult);
});
};
}
function createSingletonTests() {
return (injectionKey: symbol) => {
it('should register an object when singleton dependency is resolved', () => {
// arrange
const api = new VueDependencyInjectionApiStub();
// act
new ProvideDependenciesBuilder()
.withApi(api)
.provideDependencies();
// expect
const registeredObject = api.inject(injectionKey);
expect(registeredObject).to.be.instanceOf(Object);
});
it('should return the same instance for singleton dependency', () => {
itIsSingleton({
getter: () => {
// arrange
const api = new VueDependencyInjectionApiStub();
// act
new ProvideDependenciesBuilder()
.withApi(api)
.provideDependencies();
// expect
const registeredObject = api.inject(injectionKey);
return registeredObject;
},
});
});
};
}
class ProvideDependenciesBuilder {
private context = new ApplicationContextStub();
private api: VueDependencyInjectionApi = new VueDependencyInjectionApiStub();
public withApi(api: VueDependencyInjectionApi): this {
this.api = api;
return this;
}
public provideDependencies() {
return provideDependencies(this.context, this.api);
}
}

View File

@@ -6,13 +6,14 @@ import CardList from '@/presentation/components/Scripts/View/Cards/CardList.vue'
import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType';
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
import { UseCollectionStateStub } from '@tests/unit/shared/Stubs/UseCollectionStateStub';
import { useApplicationKey, useCollectionStateKey } from '@/presentation/injectionSymbols';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { UseApplicationStub } from '@tests/unit/shared/Stubs/UseApplicationStub';
import { UserFilterMethod, UserFilterStub } from '@tests/unit/shared/Stubs/UserFilterStub';
import { FilterResultStub } from '@tests/unit/shared/Stubs/FilterResultStub';
import { FilterChange } from '@/application/Context/State/Filter/Event/FilterChange';
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
import { IFilterChangeDetails } from '@/application/Context/State/Filter/Event/IFilterChangeDetails';
import { UseAutoUnsubscribedEventsStub } from '@tests/unit/shared/Stubs/UseAutoUnsubscribedEventsStub';
const DOM_SELECTOR_NO_MATCHES = '.search-no-matches';
const DOM_SELECTOR_CLOSE_BUTTON = '.search__query__close-button';
@@ -404,10 +405,12 @@ function mountComponent(options?: {
}) {
return shallowMount(TheScriptsView, {
provide: {
[useCollectionStateKey as symbol]:
[InjectionKeys.useCollectionState as symbol]:
() => options?.useCollectionState ?? new UseCollectionStateStub().get(),
[useApplicationKey as symbol]:
[InjectionKeys.useApplication as symbol]:
new UseApplicationStub().get(),
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
() => new UseAutoUnsubscribedEventsStub().get(),
},
propsData: {
currentView: options?.viewType === undefined ? ViewType.Tree : options.viewType,

View File

@@ -0,0 +1,68 @@
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 { FunctionKeys } from '@/TypeHelpers';
describe('UseAutoUnsubscribedEvents', () => {
describe('event collection handling', () => {
it('returns the provided event collection when initialized', () => {
// arrange
const expectedEvents = new EventSubscriptionCollectionStub();
// act
const { events: actualEvents } = useAutoUnsubscribedEvents(expectedEvents);
// 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()]);
// act
const act = () => useAutoUnsubscribedEvents(events);
// assert
expect(act).to.throw(expectedError);
});
});
describe('event unsubscription', () => {
it('unsubscribes from all events when the associated component is destroyed', () => {
// arrange
const events = new EventSubscriptionCollectionStub();
const expectedCall: FunctionKeys<EventSubscriptionCollection> = 'unsubscribeAll';
const stubComponent = shallowMount({
setup() {
useAutoUnsubscribedEvents(events);
events.register([new EventSubscriptionStub(), new EventSubscriptionStub()]);
},
template: '<div></div>',
});
events.callHistory.length = 0;
// act
stubComponent.destroy();
// assert
expect(events.callHistory).to.have.lengthOf(1);
expect(events.callHistory[0].methodName).to.equal(expectedCall);
});
});
});

View File

@@ -6,30 +6,89 @@ import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/IC
import { ApplicationContextChangedEventStub } from '@tests/unit/shared/Stubs/ApplicationContextChangedEventStub';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { IApplicationContext } from '@/application/Context/IApplicationContext';
import { IEventSubscriptionCollection } from '@/infrastructure/Events/IEventSubscriptionCollection';
import { EventSubscriptionCollectionStub } from '@tests/unit/shared/Stubs/EventSubscriptionCollectionStub';
describe('UseCollectionState', () => {
describe('context is absent', () => {
describe('parameter validation', () => {
describe('absent context', () => {
itEachAbsentObjectValue((absentValue) => {
// arrange
const expectedError = 'missing context';
const contextValue = absentValue;
// act
const act = () => useCollectionState(contextValue);
const act = () => new UseCollectionStateBuilder()
.withContext(contextValue)
.build();
// assert
expect(act).to.throw(expectedError);
});
});
describe('absent events', () => {
itEachAbsentObjectValue((absentValue) => {
// arrange
const expectedError = 'missing events';
const eventsValue = absentValue;
// act
const act = () => new UseCollectionStateBuilder()
.withEvents(eventsValue)
.build();
// assert
expect(act).to.throw(expectedError);
});
});
});
describe('listens to contextChanged event', () => {
it('registers new event listener', () => {
// arrange
const events = new EventSubscriptionCollectionStub();
const expectedSubscriptionsCount = 1;
// act
new UseCollectionStateBuilder()
.withEvents(events)
.build();
// assert
const actualSubscriptionsCount = events.subscriptionCount;
expect(actualSubscriptionsCount).to.equal(expectedSubscriptionsCount);
});
it('does not modify the state after event listener is unsubscribed', () => {
// arrange
const events = new EventSubscriptionCollectionStub();
const oldState = new CategoryCollectionStateStub();
const newState = new CategoryCollectionStateStub();
const context = new ApplicationContextStub()
.withState(oldState);
// act
const { currentState } = new UseCollectionStateBuilder()
.withContext(context)
.withEvents(events)
.build();
const stateModifierEvent = events.mostRecentSubscription;
stateModifierEvent.unsubscribe();
context.dispatchContextChange(
new ApplicationContextChangedEventStub().withNewState(newState),
);
// assert
expect(currentState.value).to.equal(oldState);
});
});
describe('currentContext', () => {
it('returns current context', () => {
// arrange
const expected = new ApplicationContextStub();
const expectedContext = new ApplicationContextStub();
// act
const { currentContext } = useCollectionState(expected);
const { currentContext } = new UseCollectionStateBuilder()
.withContext(expectedContext)
.build();
// assert
expect(currentContext).to.equal(expected);
expect(currentContext).to.equal(expectedContext);
});
});
@@ -41,24 +100,28 @@ describe('UseCollectionState', () => {
.withState(expected);
// act
const { currentState } = useCollectionState(context);
const { currentState } = new UseCollectionStateBuilder()
.withContext(context)
.build();
// assert
expect(currentState.value).to.equal(expected);
});
it('returns changed collection state', () => {
// arrange
const newState = new CategoryCollectionStateStub();
const expectedNewState = new CategoryCollectionStateStub();
const context = new ApplicationContextStub();
const { currentState } = useCollectionState(context);
const { currentState } = new UseCollectionStateBuilder()
.withContext(context)
.build();
// act
context.dispatchContextChange(
new ApplicationContextChangedEventStub().withNewState(newState),
new ApplicationContextChangedEventStub().withNewState(expectedNewState),
);
// assert
expect(currentState.value).to.equal(newState);
expect(currentState.value).to.equal(expectedNewState);
});
});
@@ -67,8 +130,7 @@ describe('UseCollectionState', () => {
itEachAbsentObjectValue((absentValue) => {
// arrange
const expectedError = 'missing state handler';
const context = new ApplicationContextStub();
const { onStateChange } = useCollectionState(context);
const { onStateChange } = new UseCollectionStateBuilder().build();
// act
const act = () => onStateChange(absentValue);
// assert
@@ -79,7 +141,9 @@ describe('UseCollectionState', () => {
// arrange
const expected = true;
const context = new ApplicationContextStub();
const { onStateChange } = useCollectionState(context);
const { onStateChange } = new UseCollectionStateBuilder()
.withContext(context)
.build();
let wasCalled = false;
// act
@@ -94,8 +158,7 @@ describe('UseCollectionState', () => {
it('call handler immediately when immediate is true', () => {
// arrange
const expected = true;
const context = new ApplicationContextStub();
const { onStateChange } = useCollectionState(context);
const { onStateChange } = new UseCollectionStateBuilder().build();
let wasCalled = false;
// act
@@ -109,8 +172,7 @@ describe('UseCollectionState', () => {
it('does not call handler immediately when immediate is false', () => {
// arrange
const expected = false;
const context = new ApplicationContextStub();
const { onStateChange } = useCollectionState(context);
const { onStateChange } = new UseCollectionStateBuilder().build();
let wasCalled = false;
// act
@@ -125,7 +187,9 @@ describe('UseCollectionState', () => {
// arrange
const expected = 5;
const context = new ApplicationContextStub();
const { onStateChange } = useCollectionState(context);
const { onStateChange } = new UseCollectionStateBuilder()
.withContext(context)
.build();
let totalCalled = 0;
// act
@@ -144,7 +208,9 @@ describe('UseCollectionState', () => {
const expected = new CategoryCollectionStateStub();
let actual: IReadOnlyCategoryCollectionState;
const context = new ApplicationContextStub();
const { onStateChange } = useCollectionState(context);
const { onStateChange } = new UseCollectionStateBuilder()
.withContext(context)
.build();
// act
onStateChange((newState) => {
@@ -159,21 +225,57 @@ describe('UseCollectionState', () => {
});
it('call handler with old state after state changes', () => {
// arrange
const expected = new CategoryCollectionStateStub();
let actual: IReadOnlyCategoryCollectionState;
const expectedState = new CategoryCollectionStateStub();
let actualState: IReadOnlyCategoryCollectionState;
const context = new ApplicationContextStub();
const { onStateChange } = useCollectionState(context);
const { onStateChange } = new UseCollectionStateBuilder()
.withContext(context)
.build();
// act
onStateChange((_, oldState) => {
actual = oldState;
actualState = oldState;
});
context.dispatchContextChange(
new ApplicationContextChangedEventStub().withOldState(expected),
new ApplicationContextChangedEventStub().withOldState(expectedState),
);
// assert
expect(actual).to.equal(expected);
expect(actualState).to.equal(expectedState);
});
describe('listens to contextChanged event', () => {
it('registers new event listener', () => {
// arrange
const events = new EventSubscriptionCollectionStub();
const { onStateChange } = new UseCollectionStateBuilder()
.withEvents(events)
.build();
const expectedSubscriptionsCount = 1;
// act
events.unsubscribeAll(); // clean count for event subscriptions before
onStateChange(() => { /* NO OP */ });
// assert
const actualSubscriptionsCount = events.subscriptionCount;
expect(actualSubscriptionsCount).to.equal(expectedSubscriptionsCount);
});
it('onStateChange is not called once event is unsubscribed', () => {
// arrange
let isCallbackCalled = false;
const callback = () => { isCallbackCalled = true; };
const context = new ApplicationContextStub();
const events = new EventSubscriptionCollectionStub();
const { onStateChange } = new UseCollectionStateBuilder()
.withEvents(events)
.withContext(context)
.build();
// act
onStateChange(callback);
const stateChangeEvent = events.mostRecentSubscription;
stateChangeEvent.unsubscribe();
context.dispatchContextChange();
// assert
expect(isCallbackCalled).to.equal(false);
});
});
});
@@ -183,9 +285,13 @@ describe('UseCollectionState', () => {
// arrange
const expectedError = 'missing state mutator';
const context = new ApplicationContextStub();
const { modifyCurrentState } = useCollectionState(context);
const { modifyCurrentState } = new UseCollectionStateBuilder()
.withContext(context)
.build();
// act
const act = () => modifyCurrentState(absentValue);
// assert
expect(act).to.throw(expectedError);
});
@@ -198,7 +304,9 @@ describe('UseCollectionState', () => {
.withOs(oldOs);
const context = new ApplicationContextStub()
.withState(state);
const { modifyCurrentState } = useCollectionState(context);
const { modifyCurrentState } = new UseCollectionStateBuilder()
.withContext(context)
.build();
// act
modifyCurrentState((mutableState) => {
@@ -217,8 +325,7 @@ describe('UseCollectionState', () => {
itEachAbsentObjectValue((absentValue) => {
// arrange
const expectedError = 'missing context mutator';
const context = new ApplicationContextStub();
const { modifyCurrentContext } = useCollectionState(context);
const { modifyCurrentContext } = new UseCollectionStateBuilder().build();
// act
const act = () => modifyCurrentContext(absentValue);
// assert
@@ -233,7 +340,9 @@ describe('UseCollectionState', () => {
.withOs(OperatingSystem.macOS);
const context = new ApplicationContextStub()
.withState(oldState);
const { modifyCurrentContext } = useCollectionState(context);
const { modifyCurrentContext } = new UseCollectionStateBuilder()
.withContext(context)
.build();
// act
modifyCurrentContext((mutableContext) => {
@@ -247,3 +356,23 @@ describe('UseCollectionState', () => {
});
});
});
class UseCollectionStateBuilder {
private context: IApplicationContext = new ApplicationContextStub();
private events: IEventSubscriptionCollection = new EventSubscriptionCollectionStub();
public withContext(context: IApplicationContext): this {
this.context = context;
return this;
}
public withEvents(events: IEventSubscriptionCollection): this {
this.events = events;
return this;
}
public build(): ReturnType<typeof useCollectionState> {
return useCollectionState(this.context, this.events);
}
}

View File

@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { InjectionKeys } from '@/presentation/injectionSymbols';
describe('injectionSymbols', () => {
Object.entries(InjectionKeys).forEach(([key, symbol]) => {
describe(`symbol for ${key}`, () => {
it('should be a symbol type', () => {
// arrange
const expectedType = Symbol;
// act
// assert
expect(symbol).to.be.instanceOf(expectedType);
});
it(`should have a description matching the key "${key}"`, () => {
// arrange
const expected = key;
// act
const actual = symbol.description;
// assert
expect(expected).to.equal(actual);
});
});
});
});

View File

@@ -1,14 +1,50 @@
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
import { IEventSubscriptionCollection } from '@/infrastructure/Events/IEventSubscriptionCollection';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
export class EventSubscriptionCollectionStub implements IEventSubscriptionCollection {
export class EventSubscriptionCollectionStub
extends StubWithObservableMethodCalls<IEventSubscriptionCollection>
implements IEventSubscriptionCollection {
private readonly subscriptions = new Array<IEventSubscription>();
public register(...subscriptions: IEventSubscription[]) {
public get mostRecentSubscription(): IEventSubscription | undefined {
if (this.subscriptions.length === 0) {
return undefined;
}
return this.subscriptions[this.subscriptions.length - 1];
}
public get subscriptionCount(): number {
return this.subscriptions.length;
}
public register(
subscriptions: IEventSubscription[],
): void {
this.registerMethodCall({
methodName: 'register',
args: [subscriptions],
});
this.subscriptions.push(...subscriptions);
}
public unsubscribeAll() {
public unsubscribeAll(): void {
this.registerMethodCall({
methodName: 'unsubscribeAll',
args: [],
});
this.subscriptions.forEach((subscription) => subscription.unsubscribe());
this.subscriptions.length = 0;
}
public unsubscribeAllAndRegister(
subscriptions: IEventSubscription[],
): void {
this.registerMethodCall({
methodName: 'unsubscribeAllAndRegister',
args: [subscriptions],
});
this.unsubscribeAll();
this.register(subscriptions);
}
}

View File

@@ -1,8 +1,16 @@
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
type UnsubscribeCallback = () => void;
export class EventSubscriptionStub implements IEventSubscription {
private currentState = SubscriptionState.Subscribed;
public get isUnsubscribed(): boolean {
return this.currentState === SubscriptionState.Unsubscribed;
}
public get isSubscribed(): boolean {
return this.currentState === SubscriptionState.Subscribed;
}
private readonly onUnsubscribe = new Array<UnsubscribeCallback>();
constructor(unsubscribeCallback?: UnsubscribeCallback) {
@@ -15,5 +23,13 @@ export class EventSubscriptionStub implements IEventSubscription {
for (const callback of this.onUnsubscribe) {
callback();
}
this.currentState = SubscriptionState.Unsubscribed;
}
}
type UnsubscribeCallback = () => void;
enum SubscriptionState {
Subscribed,
Unsubscribed,
}

View File

@@ -0,0 +1,10 @@
import { useAutoUnsubscribedEvents } from '@/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents';
import { EventSubscriptionCollectionStub } from './EventSubscriptionCollectionStub';
export class UseAutoUnsubscribedEventsStub {
public get(): ReturnType<typeof useAutoUnsubscribedEvents> {
return {
events: new EventSubscriptionCollectionStub(),
};
}
}

View File

@@ -0,0 +1,14 @@
import { InjectionKey } from 'vue';
import { VueDependencyInjectionApi } from '@/presentation/bootstrapping/DependencyProvider';
export class VueDependencyInjectionApiStub implements VueDependencyInjectionApi {
private readonly injections = new Map<unknown, unknown>();
public provide<T>(key: InjectionKey<T>, value: T): void {
this.injections.set(key, value);
}
public inject<T>(key: InjectionKey<T>): T {
return this.injections.get(key) as T;
}
}

View File

@@ -3,16 +3,18 @@ import { Constructible } from '@/TypeHelpers';
interface ISingletonTestData<T> {
getter: () => T;
expectedType: Constructible<T>;
expectedType?: Constructible<T>;
}
export function itIsSingleton<T>(test: ISingletonTestData<T>): void {
if (test.expectedType !== undefined) {
it('gets the expected type', () => {
// act
const instance = test.getter();
// assert
expect(instance).to.be.instanceOf(test.expectedType);
});
}
it('multiple calls get the same instance', () => {
// act
const instance1 = test.getter();