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:
@@ -14,12 +14,12 @@ The presentation layer uses an event-driven architecture for bidirectional react
|
|||||||
- [**`bootstrapping/`**](./../src/presentation/bootstrapping/): Registers Vue components and plugins.
|
- [**`bootstrapping/`**](./../src/presentation/bootstrapping/): Registers Vue components and plugins.
|
||||||
- [**`components/`**](./../src/presentation/components/): Contains Vue components and helpers.
|
- [**`components/`**](./../src/presentation/components/): Contains Vue components and helpers.
|
||||||
- [**`Shared/`**](./../src/presentation/components/Shared): Contains shared 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.
|
- [**`/public/`**](../src/presentation/public/): Contains static assets.
|
||||||
- [**`assets/`**](./../src/presentation/assets/styles/): Contains assets processed by Vite.
|
- [**`assets/`**](./../src/presentation/assets/styles/): Contains assets processed by Vite.
|
||||||
- [**`fonts/`**](./../src/presentation/assets/fonts/): Contains fonts.
|
- [**`fonts/`**](./../src/presentation/assets/fonts/): Contains fonts.
|
||||||
- [**`styles/`**](./../src/presentation/assets/styles/): Contains shared styles.
|
- [**`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.
|
- [**`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.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.
|
- [**`main.ts`**](./../src/presentation/main.ts): Starts Vue app.
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import { IEventSubscription } from './IEventSource';
|
|
||||||
import { IEventSubscriptionCollection } from './IEventSubscriptionCollection';
|
import { IEventSubscriptionCollection } from './IEventSubscriptionCollection';
|
||||||
|
import { IEventSubscription } from './IEventSource';
|
||||||
|
|
||||||
export class EventSubscriptionCollection implements IEventSubscriptionCollection {
|
export class EventSubscriptionCollection implements IEventSubscriptionCollection {
|
||||||
private readonly subscriptions = new Array<IEventSubscription>();
|
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);
|
this.subscriptions.push(...subscriptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,4 +22,9 @@ export class EventSubscriptionCollection implements IEventSubscriptionCollection
|
|||||||
this.subscriptions.forEach((listener) => listener.unsubscribe());
|
this.subscriptions.forEach((listener) => listener.unsubscribe());
|
||||||
this.subscriptions.splice(0, this.subscriptions.length);
|
this.subscriptions.splice(0, this.subscriptions.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public unsubscribeAllAndRegister(subscriptions: IEventSubscription[]) {
|
||||||
|
this.unsubscribeAll();
|
||||||
|
this.register(subscriptions);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
|
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
|
||||||
|
|
||||||
export interface IEventSubscriptionCollection {
|
export interface IEventSubscriptionCollection {
|
||||||
register(...subscriptions: IEventSubscription[]);
|
readonly subscriptionCount: number;
|
||||||
|
|
||||||
unsubscribeAll();
|
register(subscriptions: IEventSubscription[]): void;
|
||||||
|
unsubscribeAll(): void;
|
||||||
|
unsubscribeAllAndRegister(subscriptions: IEventSubscription[]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,31 @@
|
|||||||
import { InjectionKey, provide } from 'vue';
|
import { InjectionKey, provide, inject } from 'vue';
|
||||||
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
||||||
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
|
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
|
||||||
import {
|
import { useAutoUnsubscribedEvents } from '@/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents';
|
||||||
useCollectionStateKey, useApplicationKey, useRuntimeEnvironmentKey,
|
|
||||||
} from '@/presentation/injectionSymbols';
|
|
||||||
import { IApplicationContext } from '@/application/Context/IApplicationContext';
|
import { IApplicationContext } from '@/application/Context/IApplicationContext';
|
||||||
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
|
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
|
||||||
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
|
|
||||||
export function provideDependencies(context: IApplicationContext) {
|
export function provideDependencies(
|
||||||
registerSingleton(useApplicationKey, useApplication(context.app));
|
context: IApplicationContext,
|
||||||
registerTransient(useCollectionStateKey, () => useCollectionState(context));
|
api: VueDependencyInjectionApi = { provide, inject },
|
||||||
registerSingleton(useRuntimeEnvironmentKey, RuntimeEnvironment.CurrentEnvironment);
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerSingleton<T>(
|
|
||||||
key: InjectionKey<T>,
|
|
||||||
value: T,
|
|
||||||
) {
|
) {
|
||||||
provide(key, value);
|
const registerSingleton = <T>(key: InjectionKey<T>, value: T) => api.provide(key, value);
|
||||||
}
|
const registerTransient = <T>(
|
||||||
|
|
||||||
function registerTransient<T>(
|
|
||||||
key: InjectionKey<() => T>,
|
key: InjectionKey<() => T>,
|
||||||
factory: () => T,
|
factory: () => T,
|
||||||
) {
|
) => api.provide(key, factory);
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ import {
|
|||||||
defineComponent, PropType, computed,
|
defineComponent, PropType, computed,
|
||||||
inject,
|
inject,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { useApplicationKey } from '@/presentation/injectionSymbols';
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||||
import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
|
import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
|
||||||
import CodeInstruction from './CodeInstruction.vue';
|
import CodeInstruction from './CodeInstruction.vue';
|
||||||
@@ -77,7 +77,7 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const { info } = inject(useApplicationKey);
|
const { info } = inject(InjectionKeys.useApplication);
|
||||||
|
|
||||||
const appName = computed<string>(() => info.name);
|
const appName = computed<string>(() => info.name);
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
import {
|
import {
|
||||||
defineComponent, ref, computed, inject,
|
defineComponent, ref, computed, inject,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { useCollectionStateKey, useRuntimeEnvironmentKey } from '@/presentation/injectionSymbols';
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog';
|
import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog';
|
||||||
import { Clipboard } from '@/infrastructure/Clipboard';
|
import { Clipboard } from '@/infrastructure/Clipboard';
|
||||||
import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
|
import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
|
||||||
@@ -53,9 +53,10 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const {
|
const {
|
||||||
currentState, currentContext, onStateChange, events,
|
currentState, currentContext, onStateChange,
|
||||||
} = inject(useCollectionStateKey)();
|
} = inject(InjectionKeys.useCollectionState)();
|
||||||
const { os, isDesktop } = inject(useRuntimeEnvironmentKey);
|
const { os, isDesktop } = inject(InjectionKeys.useRuntimeEnvironment);
|
||||||
|
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
||||||
|
|
||||||
const areInstructionsVisible = ref(false);
|
const areInstructionsVisible = ref(false);
|
||||||
const canRun = computed<boolean>(() => getCanRunState(currentState.value.os, isDesktop, os));
|
const canRun = computed<boolean>(() => getCanRunState(currentState.value.os, isDesktop, os));
|
||||||
@@ -81,15 +82,18 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
onStateChange((newState) => {
|
onStateChange((newState) => {
|
||||||
|
updateCurrentCode(newState.code.current);
|
||||||
subscribeToCodeChanges(newState.code);
|
subscribeToCodeChanges(newState.code);
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
function subscribeToCodeChanges(code: IApplicationCode) {
|
function subscribeToCodeChanges(code: IApplicationCode) {
|
||||||
hasCode.value = code.current && code.current.length > 0;
|
events.unsubscribeAllAndRegister([
|
||||||
events.unsubscribeAll();
|
code.changed.on((newCode) => updateCurrentCode(newCode.code)),
|
||||||
events.register(code.changed.on((newCode) => {
|
]);
|
||||||
hasCode.value = newCode && newCode.code.length > 0;
|
}
|
||||||
}));
|
|
||||||
|
function updateCurrentCode(code: string) {
|
||||||
|
hasCode.value = code && code.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCurrentCode(): Promise<IApplicationCode> {
|
async function getCurrentCode(): Promise<IApplicationCode> {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
import {
|
import {
|
||||||
defineComponent, onUnmounted, onMounted, inject,
|
defineComponent, onUnmounted, onMounted, inject,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
import { ICodeChangedEvent } from '@/application/Context/State/Code/Event/ICodeChangedEvent';
|
import { ICodeChangedEvent } from '@/application/Context/State/Code/Event/ICodeChangedEvent';
|
||||||
import { IScript } from '@/domain/IScript';
|
import { IScript } from '@/domain/IScript';
|
||||||
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
|
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
|
||||||
@@ -37,7 +37,8 @@ export default defineComponent({
|
|||||||
NonCollapsing,
|
NonCollapsing,
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const { onStateChange, currentState, events } = inject(useCollectionStateKey)();
|
const { onStateChange, currentState } = inject(InjectionKeys.useCollectionState)();
|
||||||
|
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
||||||
|
|
||||||
const editorId = 'codeEditor';
|
const editorId = 'codeEditor';
|
||||||
let editor: ace.Ace.Editor | undefined;
|
let editor: ace.Ace.Editor | undefined;
|
||||||
@@ -61,19 +62,20 @@ export default defineComponent({
|
|||||||
newState.collection.scripting.language,
|
newState.collection.scripting.language,
|
||||||
);
|
);
|
||||||
const appCode = newState.code;
|
const appCode = newState.code;
|
||||||
const innerCode = appCode.current || getDefaultCode(newState.collection.scripting.language);
|
updateCode(appCode.current, newState.collection.scripting.language);
|
||||||
editor.setValue(innerCode, 1);
|
events.unsubscribeAllAndRegister([
|
||||||
events.unsubscribeAll();
|
appCode.changed.on((code) => handleCodeChange(code)),
|
||||||
events.register(appCode.changed.on((code) => updateCode(code)));
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCode(event: ICodeChangedEvent) {
|
function updateCode(code: string, language: ScriptingLanguage) {
|
||||||
removeCurrentHighlighting();
|
const innerCode = code || getDefaultCode(language);
|
||||||
if (event.isEmpty()) {
|
editor.setValue(innerCode, 1);
|
||||||
const defaultCode = getDefaultCode(currentState.value.collection.scripting.language);
|
|
||||||
editor.setValue(defaultCode, 1);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCodeChange(event: ICodeChangedEvent) {
|
||||||
|
removeCurrentHighlighting();
|
||||||
|
updateCode(event.code, currentState.value.collection.scripting.language);
|
||||||
editor.setValue(event.code, 1);
|
editor.setValue(event.code, 1);
|
||||||
if (event.addedScripts?.length > 0) {
|
if (event.addedScripts?.length > 0) {
|
||||||
reactToChanges(event, event.addedScripts);
|
reactToChanges(event, event.addedScripts);
|
||||||
|
|||||||
@@ -66,9 +66,10 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, ref, inject } from 'vue';
|
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 TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
|
||||||
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||||
|
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
|
||||||
import MenuOptionList from '../MenuOptionList.vue';
|
import MenuOptionList from '../MenuOptionList.vue';
|
||||||
import MenuOptionListItem from '../MenuOptionListItem.vue';
|
import MenuOptionListItem from '../MenuOptionListItem.vue';
|
||||||
import { SelectionType, SelectionTypeHandler } from './SelectionTypeHandler';
|
import { SelectionType, SelectionTypeHandler } from './SelectionTypeHandler';
|
||||||
@@ -80,28 +81,27 @@ export default defineComponent({
|
|||||||
TooltipWrapper,
|
TooltipWrapper,
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const { modifyCurrentState, onStateChange, events } = inject(useCollectionStateKey)();
|
const { modifyCurrentState, onStateChange } = inject(InjectionKeys.useCollectionState)();
|
||||||
|
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
||||||
|
|
||||||
const currentSelection = ref(SelectionType.None);
|
const currentSelection = ref(SelectionType.None);
|
||||||
|
|
||||||
let selectionTypeHandler: SelectionTypeHandler;
|
let selectionTypeHandler: SelectionTypeHandler;
|
||||||
|
|
||||||
onStateChange(() => {
|
onStateChange(() => {
|
||||||
unregisterMutators();
|
|
||||||
|
|
||||||
modifyCurrentState((state) => {
|
modifyCurrentState((state) => {
|
||||||
registerStateMutator(state);
|
selectionTypeHandler = new SelectionTypeHandler(state);
|
||||||
|
updateSelections();
|
||||||
|
events.unsubscribeAllAndRegister([
|
||||||
|
subscribeAndUpdateSelections(state),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
function unregisterMutators() {
|
function subscribeAndUpdateSelections(
|
||||||
events.unsubscribeAll();
|
state: ICategoryCollectionState,
|
||||||
}
|
): IEventSubscription {
|
||||||
|
return state.selection.changed.on(() => updateSelections());
|
||||||
function registerStateMutator(state: ICategoryCollectionState) {
|
|
||||||
selectionTypeHandler = new SelectionTypeHandler(state);
|
|
||||||
updateSelections();
|
|
||||||
events.register(state.selection.changed.on(() => updateSelections()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectType(type: SelectionType) {
|
function selectType(type: SelectionType) {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import {
|
import {
|
||||||
defineComponent, computed, inject,
|
defineComponent, computed, inject,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { useApplicationKey, useCollectionStateKey } from '@/presentation/injectionSymbols';
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||||
import MenuOptionList from './MenuOptionList.vue';
|
import MenuOptionList from './MenuOptionList.vue';
|
||||||
import MenuOptionListItem from './MenuOptionListItem.vue';
|
import MenuOptionListItem from './MenuOptionListItem.vue';
|
||||||
@@ -30,8 +30,8 @@ export default defineComponent({
|
|||||||
MenuOptionListItem,
|
MenuOptionListItem,
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const { modifyCurrentContext, currentState } = inject(useCollectionStateKey)();
|
const { modifyCurrentContext, currentState } = inject(InjectionKeys.useCollectionState)();
|
||||||
const { application } = inject(useApplicationKey);
|
const { application } = inject(InjectionKeys.useApplication);
|
||||||
|
|
||||||
const allOses = computed<ReadonlyArray<IOsViewModel>>(() => (
|
const allOses = computed<ReadonlyArray<IOsViewModel>>(() => (
|
||||||
application.getSupportedOsList() ?? [])
|
application.getSupportedOsList() ?? [])
|
||||||
|
|||||||
@@ -11,10 +11,11 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
defineComponent, ref, onUnmounted, inject,
|
defineComponent, ref, inject,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter';
|
import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter';
|
||||||
|
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
|
||||||
import TheOsChanger from './TheOsChanger.vue';
|
import TheOsChanger from './TheOsChanger.vue';
|
||||||
import TheSelector from './Selector/TheSelector.vue';
|
import TheSelector from './Selector/TheSelector.vue';
|
||||||
import TheViewChanger from './View/TheViewChanger.vue';
|
import TheViewChanger from './View/TheViewChanger.vue';
|
||||||
@@ -26,31 +27,26 @@ export default defineComponent({
|
|||||||
TheViewChanger,
|
TheViewChanger,
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const { onStateChange, events } = inject(useCollectionStateKey)();
|
const { onStateChange } = inject(InjectionKeys.useCollectionState)();
|
||||||
|
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
||||||
|
|
||||||
const isSearching = ref(false);
|
const isSearching = ref(false);
|
||||||
|
|
||||||
onStateChange((state) => {
|
onStateChange((state) => {
|
||||||
subscribeToFilterChanges(state.filter);
|
events.unsubscribeAllAndRegister([
|
||||||
|
subscribeToFilterChanges(state.filter),
|
||||||
|
]);
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
onUnmounted(() => {
|
function subscribeToFilterChanges(
|
||||||
unsubscribeAll();
|
filter: IReadOnlyUserFilter,
|
||||||
});
|
): IEventSubscription {
|
||||||
|
return filter.filterChanged.on((event) => {
|
||||||
function subscribeToFilterChanges(filter: IReadOnlyUserFilter) {
|
|
||||||
events.register(
|
|
||||||
filter.filterChanged.on((event) => {
|
|
||||||
event.visit({
|
event.visit({
|
||||||
onApply: () => { isSearching.value = true; },
|
onApply: () => { isSearching.value = true; },
|
||||||
onClear: () => { isSearching.value = false; },
|
onClear: () => { isSearching.value = false; },
|
||||||
});
|
});
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function unsubscribeAll() {
|
|
||||||
events.unsubscribeAll();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent, onUnmounted } from 'vue';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
emits: {
|
emits: {
|
||||||
@@ -46,6 +46,10 @@ export default defineComponent({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopResize();
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cursorCssValue,
|
cursorCssValue,
|
||||||
startResize,
|
startResize,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import {
|
|||||||
defineComponent, ref, onMounted, onUnmounted, computed,
|
defineComponent, ref, onMounted, onUnmounted, computed,
|
||||||
inject,
|
inject,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
import SizeObserver from '@/presentation/components/Shared/SizeObserver.vue';
|
import SizeObserver from '@/presentation/components/Shared/SizeObserver.vue';
|
||||||
import { hasDirective } from './NonCollapsingDirective';
|
import { hasDirective } from './NonCollapsingDirective';
|
||||||
import CardListItem from './CardListItem.vue';
|
import CardListItem from './CardListItem.vue';
|
||||||
@@ -48,7 +48,7 @@ export default defineComponent({
|
|||||||
SizeObserver,
|
SizeObserver,
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const { currentState, onStateChange } = inject(useCollectionStateKey)();
|
const { currentState, onStateChange } = inject(InjectionKeys.useCollectionState)();
|
||||||
|
|
||||||
const width = ref<number>(0);
|
const width = ref<number>(0);
|
||||||
const categoryIds = computed<ReadonlyArray<number>>(() => currentState
|
const categoryIds = computed<ReadonlyArray<number>>(() => currentState
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ import {
|
|||||||
defineComponent, ref, watch, computed,
|
defineComponent, ref, watch, computed,
|
||||||
inject,
|
inject,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
import ScriptsTree from '@/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue';
|
import ScriptsTree from '@/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue';
|
||||||
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
|
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
|
||||||
|
|
||||||
@@ -76,7 +76,8 @@ export default defineComponent({
|
|||||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||||
},
|
},
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
const { events, onStateChange, currentState } = inject(useCollectionStateKey)();
|
const { onStateChange, currentState } = inject(InjectionKeys.useCollectionState)();
|
||||||
|
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
||||||
|
|
||||||
const isExpanded = computed({
|
const isExpanded = computed({
|
||||||
get: () => {
|
get: () => {
|
||||||
@@ -106,12 +107,13 @@ export default defineComponent({
|
|||||||
isExpanded.value = false;
|
isExpanded.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
onStateChange(async (state) => {
|
onStateChange((state) => {
|
||||||
events.unsubscribeAll();
|
events.unsubscribeAllAndRegister([
|
||||||
events.register(state.selection.changed.on(
|
state.selection.changed.on(
|
||||||
() => updateSelectionIndicators(props.categoryId),
|
() => updateSelectionIndicators(props.categoryId),
|
||||||
));
|
),
|
||||||
await updateSelectionIndicators(props.categoryId);
|
]);
|
||||||
|
updateSelectionIndicators(props.categoryId);
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -124,7 +126,7 @@ export default defineComponent({
|
|||||||
cardElement.value.scrollIntoView({ behavior: 'smooth' });
|
cardElement.value.scrollIntoView({ behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateSelectionIndicators(categoryId: number) {
|
function updateSelectionIndicators(categoryId: number) {
|
||||||
const category = currentState.value.collection.findCategory(categoryId);
|
const category = currentState.value.collection.findCategory(categoryId);
|
||||||
const { selection } = currentState.value;
|
const { selection } = currentState.value;
|
||||||
isAnyChildSelected.value = category ? selection.isAnySelected(category) : false;
|
isAnyChildSelected.value = category ? selection.isAnySelected(category) : false;
|
||||||
|
|||||||
@@ -17,12 +17,13 @@
|
|||||||
import {
|
import {
|
||||||
defineComponent, watch, ref, inject,
|
defineComponent, watch, ref, inject,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
import { IScript } from '@/domain/IScript';
|
import { IScript } from '@/domain/IScript';
|
||||||
import { ICategory } from '@/domain/ICategory';
|
import { ICategory } from '@/domain/ICategory';
|
||||||
import { ICategoryCollectionState, IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
import { ICategoryCollectionState, IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||||
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
|
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
|
||||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||||
|
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
|
||||||
import {
|
import {
|
||||||
parseAllCategories, parseSingleCategory, getScriptNodeId, getCategoryNodeId, getCategoryId,
|
parseAllCategories, parseSingleCategory, getScriptNodeId, getCategoryNodeId, getCategoryId,
|
||||||
getScriptId,
|
getScriptId,
|
||||||
@@ -43,8 +44,9 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const {
|
const {
|
||||||
modifyCurrentState, currentState, onStateChange, events,
|
modifyCurrentState, currentState, onStateChange,
|
||||||
} = inject(useCollectionStateKey)();
|
} = inject(InjectionKeys.useCollectionState)();
|
||||||
|
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
||||||
|
|
||||||
const nodes = ref<ReadonlyArray<INodeContent>>([]);
|
const nodes = ref<ReadonlyArray<INodeContent>>([]);
|
||||||
const selectedNodeIds = ref<ReadonlyArray<string>>([]);
|
const selectedNodeIds = ref<ReadonlyArray<string>>([]);
|
||||||
@@ -54,7 +56,7 @@ export default defineComponent({
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.categoryId,
|
() => props.categoryId,
|
||||||
async (newCategoryId) => { await setNodes(newCategoryId); },
|
(newCategoryId) => setNodes(newCategoryId),
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -63,8 +65,7 @@ export default defineComponent({
|
|||||||
if (!props.categoryId) {
|
if (!props.categoryId) {
|
||||||
nodes.value = parseAllCategories(state.collection);
|
nodes.value = parseAllCategories(state.collection);
|
||||||
}
|
}
|
||||||
events.unsubscribeAll();
|
events.unsubscribeAllAndRegister(subscribeToState(state));
|
||||||
subscribeToState(state);
|
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
function toggleNodeSelection(event: INodeSelectedEvent) {
|
function toggleNodeSelection(event: INodeSelectedEvent) {
|
||||||
@@ -87,7 +88,7 @@ export default defineComponent({
|
|||||||
|| containsCategory(node, filtered.categoryMatches);
|
|| containsCategory(node, filtered.categoryMatches);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setNodes(categoryId?: number) {
|
function setNodes(categoryId?: number) {
|
||||||
if (categoryId) {
|
if (categoryId) {
|
||||||
nodes.value = parseSingleCategory(categoryId, currentState.value.collection);
|
nodes.value = parseSingleCategory(categoryId, currentState.value.collection);
|
||||||
} else {
|
} else {
|
||||||
@@ -97,8 +98,10 @@ export default defineComponent({
|
|||||||
.map((selected) => getScriptNodeId(selected.script));
|
.map((selected) => getScriptNodeId(selected.script));
|
||||||
}
|
}
|
||||||
|
|
||||||
function subscribeToState(state: IReadOnlyCategoryCollectionState) {
|
function subscribeToState(
|
||||||
events.register(
|
state: IReadOnlyCategoryCollectionState,
|
||||||
|
): IEventSubscription[] {
|
||||||
|
return [
|
||||||
state.selection.changed.on((scripts) => handleSelectionChanged(scripts)),
|
state.selection.changed.on((scripts) => handleSelectionChanged(scripts)),
|
||||||
state.filter.filterChanged.on((event) => {
|
state.filter.filterChanged.on((event) => {
|
||||||
event.visit({
|
event.visit({
|
||||||
@@ -111,7 +114,7 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
);
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCurrentFilter(currentFilter: IFilterResult | undefined) {
|
function setCurrentFilter(currentFilter: IFilterResult | undefined) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
computed, inject,
|
computed, inject,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||||
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
import { IReverter } from './Reverter/IReverter';
|
import { IReverter } from './Reverter/IReverter';
|
||||||
import { INodeContent } from './INodeContent';
|
import { INodeContent } from './INodeContent';
|
||||||
import { getReverter } from './Reverter/ReverterFactory';
|
import { getReverter } from './Reverter/ReverterFactory';
|
||||||
@@ -30,8 +30,9 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const {
|
const {
|
||||||
currentState, modifyCurrentState, onStateChange, events,
|
currentState, modifyCurrentState, onStateChange,
|
||||||
} = inject(useCollectionStateKey)();
|
} = inject(InjectionKeys.useCollectionState)();
|
||||||
|
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
||||||
|
|
||||||
const isReverted = ref(false);
|
const isReverted = ref(false);
|
||||||
|
|
||||||
@@ -39,24 +40,23 @@ export default defineComponent({
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.node,
|
() => props.node,
|
||||||
async (node) => { await onNodeChanged(node); },
|
(node) => onNodeChanged(node),
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
onStateChange((newState) => {
|
onStateChange((newState) => {
|
||||||
updateRevertStatusFromState(newState.selection.selectedScripts);
|
updateRevertStatusFromState(newState.selection.selectedScripts);
|
||||||
events.unsubscribeAll();
|
events.unsubscribeAllAndRegister([
|
||||||
events.register(
|
|
||||||
newState.selection.changed.on((scripts) => updateRevertStatusFromState(scripts)),
|
newState.selection.changed.on((scripts) => updateRevertStatusFromState(scripts)),
|
||||||
);
|
]);
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
async function onNodeChanged(node: INodeContent) {
|
function onNodeChanged(node: INodeContent) {
|
||||||
handler = getReverter(node, currentState.value.collection);
|
handler = getReverter(node, currentState.value.collection);
|
||||||
updateRevertStatusFromState(currentState.value.selection.selectedScripts);
|
updateRevertStatusFromState(currentState.value.selection.selectedScripts);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateRevertStatusFromState(scripts: ReadonlyArray<SelectedScript>) {
|
function updateRevertStatusFromState(scripts: ReadonlyArray<SelectedScript>) {
|
||||||
isReverted.value = handler?.getState(scripts) ?? false;
|
isReverted.value = handler?.getState(scripts) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import {
|
|||||||
defineComponent, PropType, ref, computed,
|
defineComponent, PropType, ref, computed,
|
||||||
inject,
|
inject,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { useApplicationKey, useCollectionStateKey } from '@/presentation/injectionSymbols';
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
import ScriptsTree from '@/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue';
|
import ScriptsTree from '@/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue';
|
||||||
import CardList from '@/presentation/components/Scripts/View/Cards/CardList.vue';
|
import CardList from '@/presentation/components/Scripts/View/Cards/CardList.vue';
|
||||||
import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType';
|
import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType';
|
||||||
@@ -60,8 +60,9 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const { modifyCurrentState, onStateChange, events } = inject(useCollectionStateKey)();
|
const { modifyCurrentState, onStateChange } = inject(InjectionKeys.useCollectionState)();
|
||||||
const { info } = inject(useApplicationKey);
|
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
||||||
|
const { info } = inject(InjectionKeys.useApplication);
|
||||||
|
|
||||||
const repositoryUrl = computed<string>(() => info.repositoryWebUrl);
|
const repositoryUrl = computed<string>(() => info.repositoryWebUrl);
|
||||||
const searchQuery = ref<string | undefined>();
|
const searchQuery = ref<string | undefined>();
|
||||||
@@ -77,9 +78,10 @@ export default defineComponent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
onStateChange((newState) => {
|
onStateChange((newState) => {
|
||||||
events.unsubscribeAll();
|
|
||||||
updateFromInitialFilter(newState.filter.currentFilter);
|
updateFromInitialFilter(newState.filter.currentFilter);
|
||||||
subscribeToFilterChanges(newState.filter);
|
events.unsubscribeAllAndRegister([
|
||||||
|
subscribeToFilterChanges(newState.filter),
|
||||||
|
]);
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
function clearSearchQuery() {
|
function clearSearchQuery() {
|
||||||
@@ -95,8 +97,7 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function subscribeToFilterChanges(filter: IReadOnlyUserFilter) {
|
function subscribeToFilterChanges(filter: IReadOnlyUserFilter) {
|
||||||
events.register(
|
return filter.filterChanged.on((event) => {
|
||||||
filter.filterChanged.on((event) => {
|
|
||||||
event.visit({
|
event.visit({
|
||||||
onApply: (newFilter) => {
|
onApply: (newFilter) => {
|
||||||
searchQuery.value = newFilter.query;
|
searchQuery.value = newFilter.query;
|
||||||
@@ -106,8 +107,7 @@ export default defineComponent({
|
|||||||
searchQuery.value = undefined;
|
searchQuery.value = undefined;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,23 +1,27 @@
|
|||||||
import { ref, computed, readonly } from 'vue';
|
import {
|
||||||
|
ref, computed, readonly,
|
||||||
|
} from 'vue';
|
||||||
import { IApplicationContext, IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
|
import { IApplicationContext, IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
|
||||||
import { ICategoryCollectionState, IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
import { ICategoryCollectionState, IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||||
import { EventSubscriptionCollection } from '@/infrastructure/Events/EventSubscriptionCollection';
|
|
||||||
import { IEventSubscriptionCollection } from '@/infrastructure/Events/IEventSubscriptionCollection';
|
import { IEventSubscriptionCollection } from '@/infrastructure/Events/IEventSubscriptionCollection';
|
||||||
|
|
||||||
export function useCollectionState(context: IApplicationContext) {
|
export function useCollectionState(
|
||||||
|
context: IApplicationContext,
|
||||||
|
events: IEventSubscriptionCollection,
|
||||||
|
) {
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error('missing context');
|
throw new Error('missing context');
|
||||||
}
|
}
|
||||||
|
if (!events) {
|
||||||
const events = new EventSubscriptionCollection();
|
throw new Error('missing events');
|
||||||
const ownEvents = new EventSubscriptionCollection();
|
}
|
||||||
|
|
||||||
const currentState = ref<ICategoryCollectionState>(context.state);
|
const currentState = ref<ICategoryCollectionState>(context.state);
|
||||||
ownEvents.register(
|
events.register([
|
||||||
context.contextChanged.on((event) => {
|
context.contextChanged.on((event) => {
|
||||||
currentState.value = event.newState;
|
currentState.value = event.newState;
|
||||||
}),
|
}),
|
||||||
);
|
]);
|
||||||
|
|
||||||
const defaultSettings: IStateCallbackSettings = {
|
const defaultSettings: IStateCallbackSettings = {
|
||||||
immediate: false,
|
immediate: false,
|
||||||
@@ -29,11 +33,11 @@ export function useCollectionState(context: IApplicationContext) {
|
|||||||
if (!handler) {
|
if (!handler) {
|
||||||
throw new Error('missing state handler');
|
throw new Error('missing state handler');
|
||||||
}
|
}
|
||||||
ownEvents.register(
|
events.register([
|
||||||
context.contextChanged.on((event) => {
|
context.contextChanged.on((event) => {
|
||||||
handler(event.newState, event.oldState);
|
handler(event.newState, event.oldState);
|
||||||
}),
|
}),
|
||||||
);
|
]);
|
||||||
const defaultedSettings: IStateCallbackSettings = {
|
const defaultedSettings: IStateCallbackSettings = {
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
...settings,
|
...settings,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, inject } from 'vue';
|
import { defineComponent, inject } from 'vue';
|
||||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||||
import { useRuntimeEnvironmentKey } from '@/presentation/injectionSymbols';
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
import DownloadUrlListItem from './DownloadUrlListItem.vue';
|
import DownloadUrlListItem from './DownloadUrlListItem.vue';
|
||||||
|
|
||||||
const supportedOperativeSystems: readonly OperatingSystem[] = [
|
const supportedOperativeSystems: readonly OperatingSystem[] = [
|
||||||
@@ -34,7 +34,7 @@ export default defineComponent({
|
|||||||
DownloadUrlListItem,
|
DownloadUrlListItem,
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const { os: currentOs } = inject(useRuntimeEnvironmentKey);
|
const { os: currentOs } = inject(InjectionKeys.useRuntimeEnvironment);
|
||||||
const supportedDesktops = [
|
const supportedDesktops = [
|
||||||
...supportedOperativeSystems,
|
...supportedOperativeSystems,
|
||||||
].sort((os) => (os === currentOs ? 0 : 1));
|
].sort((os) => (os === currentOs ? 0 : 1));
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
defineComponent, PropType, computed,
|
defineComponent, PropType, computed,
|
||||||
inject,
|
inject,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { useApplicationKey, useRuntimeEnvironmentKey } from '@/presentation/injectionSymbols';
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
@@ -25,8 +25,8 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const { info } = inject(useApplicationKey);
|
const { info } = inject(InjectionKeys.useApplication);
|
||||||
const { os: currentOs } = inject(useRuntimeEnvironmentKey);
|
const { os: currentOs } = inject(InjectionKeys.useRuntimeEnvironment);
|
||||||
|
|
||||||
const isCurrentOs = computed<boolean>(() => {
|
const isCurrentOs = computed<boolean>(() => {
|
||||||
return currentOs === props.operatingSystem;
|
return currentOs === props.operatingSystem;
|
||||||
|
|||||||
@@ -42,12 +42,12 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, computed, inject } from 'vue';
|
import { defineComponent, computed, inject } from 'vue';
|
||||||
import { useApplicationKey, useRuntimeEnvironmentKey } from '@/presentation/injectionSymbols';
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
setup() {
|
setup() {
|
||||||
const { info } = inject(useApplicationKey);
|
const { info } = inject(InjectionKeys.useApplication);
|
||||||
const { isDesktop } = inject(useRuntimeEnvironmentKey);
|
const { isDesktop } = inject(InjectionKeys.useRuntimeEnvironment);
|
||||||
|
|
||||||
const repositoryUrl = computed<string>(() => info.repositoryUrl);
|
const repositoryUrl = computed<string>(() => info.repositoryUrl);
|
||||||
const feedbackUrl = computed<string>(() => info.feedbackUrl);
|
const feedbackUrl = computed<string>(() => info.feedbackUrl);
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ import {
|
|||||||
defineComponent, ref, computed, inject,
|
defineComponent, ref, computed, inject,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.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 DownloadUrlList from './DownloadUrlList.vue';
|
||||||
import PrivacyPolicy from './PrivacyPolicy.vue';
|
import PrivacyPolicy from './PrivacyPolicy.vue';
|
||||||
|
|
||||||
@@ -59,8 +59,8 @@ export default defineComponent({
|
|||||||
DownloadUrlList,
|
DownloadUrlList,
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const { info } = inject(useApplicationKey);
|
const { info } = inject(InjectionKeys.useApplication);
|
||||||
const { isDesktop } = inject(useRuntimeEnvironmentKey);
|
const { isDesktop } = inject(InjectionKeys.useRuntimeEnvironment);
|
||||||
|
|
||||||
const isPrivacyDialogVisible = ref(false);
|
const isPrivacyDialogVisible = ref(false);
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, computed, inject } from 'vue';
|
import { defineComponent, computed, inject } from 'vue';
|
||||||
import { useApplicationKey } from '@/presentation/injectionSymbols';
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
setup() {
|
setup() {
|
||||||
const { info } = inject(useApplicationKey);
|
const { info } = inject(InjectionKeys.useApplication);
|
||||||
|
|
||||||
const title = computed(() => info.name);
|
const title = computed(() => info.name);
|
||||||
const subtitle = computed(() => info.slogan);
|
const subtitle = computed(() => info.slogan);
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ import {
|
|||||||
defineComponent, ref, watch, computed,
|
defineComponent, ref, watch, computed,
|
||||||
inject,
|
inject,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
import { NonCollapsing } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective';
|
import { NonCollapsing } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective';
|
||||||
import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter';
|
import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter';
|
||||||
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
|
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
|
||||||
|
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
directives: {
|
directives: {
|
||||||
@@ -28,8 +29,9 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const {
|
const {
|
||||||
modifyCurrentState, onStateChange, events, currentState,
|
modifyCurrentState, onStateChange, currentState,
|
||||||
} = inject(useCollectionStateKey)();
|
} = inject(InjectionKeys.useCollectionState)();
|
||||||
|
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
||||||
|
|
||||||
const searchPlaceholder = computed<string>(() => {
|
const searchPlaceholder = computed<string>(() => {
|
||||||
const { totalScripts } = currentState.value.collection;
|
const { totalScripts } = currentState.value.collection;
|
||||||
@@ -51,18 +53,20 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
onStateChange((newState) => {
|
onStateChange((newState) => {
|
||||||
events.unsubscribeAll();
|
|
||||||
updateFromInitialFilter(newState.filter.currentFilter);
|
updateFromInitialFilter(newState.filter.currentFilter);
|
||||||
subscribeToFilterChanges(newState.filter);
|
events.unsubscribeAllAndRegister([
|
||||||
|
subscribeToFilterChanges(newState.filter),
|
||||||
|
]);
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
function updateFromInitialFilter(filter?: IFilterResult) {
|
function updateFromInitialFilter(filter?: IFilterResult) {
|
||||||
searchQuery.value = filter?.query || '';
|
searchQuery.value = filter?.query || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function subscribeToFilterChanges(filter: IReadOnlyUserFilter) {
|
function subscribeToFilterChanges(
|
||||||
events.register(
|
filter: IReadOnlyUserFilter,
|
||||||
filter.filterChanged.on((event) => {
|
): IEventSubscription {
|
||||||
|
return filter.filterChanged.on((event) => {
|
||||||
event.visit({
|
event.visit({
|
||||||
onApply: (result) => {
|
onApply: (result) => {
|
||||||
searchQuery.value = result.query;
|
searchQuery.value = result.query;
|
||||||
@@ -71,8 +75,7 @@ export default defineComponent({
|
|||||||
searchQuery.value = '';
|
searchQuery.value = '';
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
||||||
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
|
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
|
||||||
import { useRuntimeEnvironment } from '@/presentation/components/Shared/Hooks/UseRuntimeEnvironment';
|
import { useRuntimeEnvironment } from '@/presentation/components/Shared/Hooks/UseRuntimeEnvironment';
|
||||||
|
import type { useAutoUnsubscribedEvents } from './components/Shared/Hooks/UseAutoUnsubscribedEvents';
|
||||||
import type { InjectionKey } from 'vue';
|
import type { InjectionKey } from 'vue';
|
||||||
|
|
||||||
export const useCollectionStateKey = defineTransientKey<ReturnType<typeof useCollectionState>>('useCollectionState');
|
export const InjectionKeys = {
|
||||||
export const useApplicationKey = defineSingletonKey<ReturnType<typeof useApplication>>('useApplication');
|
useCollectionState: defineTransientKey<ReturnType<typeof useCollectionState>>('useCollectionState'),
|
||||||
export const useRuntimeEnvironmentKey = defineSingletonKey<ReturnType<typeof useRuntimeEnvironment>>('useRuntimeEnvironment');
|
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>;
|
return Symbol(key) as InjectionKey<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function defineTransientKey<T>(key: string) {
|
function defineTransientKey<T>(key: string): InjectionKey<() => T> {
|
||||||
return Symbol(key) as InjectionKey<() => T>;
|
return Symbol(key) as InjectionKey<() => T>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,172 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { EventSubscriptionCollection } from '@/infrastructure/Events/EventSubscriptionCollection';
|
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';
|
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
|
||||||
|
|
||||||
describe('EventSubscriptionCollection', () => {
|
describe('EventSubscriptionCollection', () => {
|
||||||
it('unsubscribeAll unsubscribes from all registered subscriptions', () => {
|
describe('register', () => {
|
||||||
|
it('increments `subscriptionCount` for each registration', () => {
|
||||||
// arrange
|
// arrange
|
||||||
const sut = new EventSubscriptionCollection();
|
const sut = new EventSubscriptionCollection();
|
||||||
const expected = ['unsubscribed1', 'unsubscribed2'];
|
const subscriptions = createSubscriptionStubList(2);
|
||||||
const actual = new Array<string>();
|
|
||||||
const subscriptions: IEventSubscription[] = [
|
|
||||||
{ unsubscribe: () => actual.push(expected[0]) },
|
|
||||||
{ unsubscribe: () => actual.push(expected[1]) },
|
|
||||||
];
|
|
||||||
// act
|
// act
|
||||||
sut.register(...subscriptions);
|
sut.register(subscriptions);
|
||||||
sut.unsubscribeAll();
|
|
||||||
// assert
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,13 +6,14 @@ import CardList from '@/presentation/components/Scripts/View/Cards/CardList.vue'
|
|||||||
import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType';
|
import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType';
|
||||||
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
||||||
import { UseCollectionStateStub } from '@tests/unit/shared/Stubs/UseCollectionStateStub';
|
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 { UseApplicationStub } from '@tests/unit/shared/Stubs/UseApplicationStub';
|
||||||
import { UserFilterMethod, UserFilterStub } from '@tests/unit/shared/Stubs/UserFilterStub';
|
import { UserFilterMethod, UserFilterStub } from '@tests/unit/shared/Stubs/UserFilterStub';
|
||||||
import { FilterResultStub } from '@tests/unit/shared/Stubs/FilterResultStub';
|
import { FilterResultStub } from '@tests/unit/shared/Stubs/FilterResultStub';
|
||||||
import { FilterChange } from '@/application/Context/State/Filter/Event/FilterChange';
|
import { FilterChange } from '@/application/Context/State/Filter/Event/FilterChange';
|
||||||
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
|
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
|
||||||
import { IFilterChangeDetails } from '@/application/Context/State/Filter/Event/IFilterChangeDetails';
|
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_NO_MATCHES = '.search-no-matches';
|
||||||
const DOM_SELECTOR_CLOSE_BUTTON = '.search__query__close-button';
|
const DOM_SELECTOR_CLOSE_BUTTON = '.search__query__close-button';
|
||||||
@@ -404,10 +405,12 @@ function mountComponent(options?: {
|
|||||||
}) {
|
}) {
|
||||||
return shallowMount(TheScriptsView, {
|
return shallowMount(TheScriptsView, {
|
||||||
provide: {
|
provide: {
|
||||||
[useCollectionStateKey as symbol]:
|
[InjectionKeys.useCollectionState as symbol]:
|
||||||
() => options?.useCollectionState ?? new UseCollectionStateStub().get(),
|
() => options?.useCollectionState ?? new UseCollectionStateStub().get(),
|
||||||
[useApplicationKey as symbol]:
|
[InjectionKeys.useApplication as symbol]:
|
||||||
new UseApplicationStub().get(),
|
new UseApplicationStub().get(),
|
||||||
|
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
|
||||||
|
() => new UseAutoUnsubscribedEventsStub().get(),
|
||||||
},
|
},
|
||||||
propsData: {
|
propsData: {
|
||||||
currentView: options?.viewType === undefined ? ViewType.Tree : options.viewType,
|
currentView: options?.viewType === undefined ? ViewType.Tree : options.viewType,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,30 +6,89 @@ import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/IC
|
|||||||
import { ApplicationContextChangedEventStub } from '@tests/unit/shared/Stubs/ApplicationContextChangedEventStub';
|
import { ApplicationContextChangedEventStub } from '@tests/unit/shared/Stubs/ApplicationContextChangedEventStub';
|
||||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||||
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
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('UseCollectionState', () => {
|
||||||
describe('context is absent', () => {
|
describe('parameter validation', () => {
|
||||||
|
describe('absent context', () => {
|
||||||
itEachAbsentObjectValue((absentValue) => {
|
itEachAbsentObjectValue((absentValue) => {
|
||||||
// arrange
|
// arrange
|
||||||
const expectedError = 'missing context';
|
const expectedError = 'missing context';
|
||||||
const contextValue = absentValue;
|
const contextValue = absentValue;
|
||||||
// act
|
// act
|
||||||
const act = () => useCollectionState(contextValue);
|
const act = () => new UseCollectionStateBuilder()
|
||||||
|
.withContext(contextValue)
|
||||||
|
.build();
|
||||||
// assert
|
// assert
|
||||||
expect(act).to.throw(expectedError);
|
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', () => {
|
describe('currentContext', () => {
|
||||||
it('returns current context', () => {
|
it('returns current context', () => {
|
||||||
// arrange
|
// arrange
|
||||||
const expected = new ApplicationContextStub();
|
const expectedContext = new ApplicationContextStub();
|
||||||
|
|
||||||
// act
|
// act
|
||||||
const { currentContext } = useCollectionState(expected);
|
const { currentContext } = new UseCollectionStateBuilder()
|
||||||
|
.withContext(expectedContext)
|
||||||
|
.build();
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
expect(currentContext).to.equal(expected);
|
expect(currentContext).to.equal(expectedContext);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,24 +100,28 @@ describe('UseCollectionState', () => {
|
|||||||
.withState(expected);
|
.withState(expected);
|
||||||
|
|
||||||
// act
|
// act
|
||||||
const { currentState } = useCollectionState(context);
|
const { currentState } = new UseCollectionStateBuilder()
|
||||||
|
.withContext(context)
|
||||||
|
.build();
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
expect(currentState.value).to.equal(expected);
|
expect(currentState.value).to.equal(expected);
|
||||||
});
|
});
|
||||||
it('returns changed collection state', () => {
|
it('returns changed collection state', () => {
|
||||||
// arrange
|
// arrange
|
||||||
const newState = new CategoryCollectionStateStub();
|
const expectedNewState = new CategoryCollectionStateStub();
|
||||||
const context = new ApplicationContextStub();
|
const context = new ApplicationContextStub();
|
||||||
const { currentState } = useCollectionState(context);
|
const { currentState } = new UseCollectionStateBuilder()
|
||||||
|
.withContext(context)
|
||||||
|
.build();
|
||||||
|
|
||||||
// act
|
// act
|
||||||
context.dispatchContextChange(
|
context.dispatchContextChange(
|
||||||
new ApplicationContextChangedEventStub().withNewState(newState),
|
new ApplicationContextChangedEventStub().withNewState(expectedNewState),
|
||||||
);
|
);
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
expect(currentState.value).to.equal(newState);
|
expect(currentState.value).to.equal(expectedNewState);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,8 +130,7 @@ describe('UseCollectionState', () => {
|
|||||||
itEachAbsentObjectValue((absentValue) => {
|
itEachAbsentObjectValue((absentValue) => {
|
||||||
// arrange
|
// arrange
|
||||||
const expectedError = 'missing state handler';
|
const expectedError = 'missing state handler';
|
||||||
const context = new ApplicationContextStub();
|
const { onStateChange } = new UseCollectionStateBuilder().build();
|
||||||
const { onStateChange } = useCollectionState(context);
|
|
||||||
// act
|
// act
|
||||||
const act = () => onStateChange(absentValue);
|
const act = () => onStateChange(absentValue);
|
||||||
// assert
|
// assert
|
||||||
@@ -79,7 +141,9 @@ describe('UseCollectionState', () => {
|
|||||||
// arrange
|
// arrange
|
||||||
const expected = true;
|
const expected = true;
|
||||||
const context = new ApplicationContextStub();
|
const context = new ApplicationContextStub();
|
||||||
const { onStateChange } = useCollectionState(context);
|
const { onStateChange } = new UseCollectionStateBuilder()
|
||||||
|
.withContext(context)
|
||||||
|
.build();
|
||||||
let wasCalled = false;
|
let wasCalled = false;
|
||||||
|
|
||||||
// act
|
// act
|
||||||
@@ -94,8 +158,7 @@ describe('UseCollectionState', () => {
|
|||||||
it('call handler immediately when immediate is true', () => {
|
it('call handler immediately when immediate is true', () => {
|
||||||
// arrange
|
// arrange
|
||||||
const expected = true;
|
const expected = true;
|
||||||
const context = new ApplicationContextStub();
|
const { onStateChange } = new UseCollectionStateBuilder().build();
|
||||||
const { onStateChange } = useCollectionState(context);
|
|
||||||
let wasCalled = false;
|
let wasCalled = false;
|
||||||
|
|
||||||
// act
|
// act
|
||||||
@@ -109,8 +172,7 @@ describe('UseCollectionState', () => {
|
|||||||
it('does not call handler immediately when immediate is false', () => {
|
it('does not call handler immediately when immediate is false', () => {
|
||||||
// arrange
|
// arrange
|
||||||
const expected = false;
|
const expected = false;
|
||||||
const context = new ApplicationContextStub();
|
const { onStateChange } = new UseCollectionStateBuilder().build();
|
||||||
const { onStateChange } = useCollectionState(context);
|
|
||||||
let wasCalled = false;
|
let wasCalled = false;
|
||||||
|
|
||||||
// act
|
// act
|
||||||
@@ -125,7 +187,9 @@ describe('UseCollectionState', () => {
|
|||||||
// arrange
|
// arrange
|
||||||
const expected = 5;
|
const expected = 5;
|
||||||
const context = new ApplicationContextStub();
|
const context = new ApplicationContextStub();
|
||||||
const { onStateChange } = useCollectionState(context);
|
const { onStateChange } = new UseCollectionStateBuilder()
|
||||||
|
.withContext(context)
|
||||||
|
.build();
|
||||||
let totalCalled = 0;
|
let totalCalled = 0;
|
||||||
|
|
||||||
// act
|
// act
|
||||||
@@ -144,7 +208,9 @@ describe('UseCollectionState', () => {
|
|||||||
const expected = new CategoryCollectionStateStub();
|
const expected = new CategoryCollectionStateStub();
|
||||||
let actual: IReadOnlyCategoryCollectionState;
|
let actual: IReadOnlyCategoryCollectionState;
|
||||||
const context = new ApplicationContextStub();
|
const context = new ApplicationContextStub();
|
||||||
const { onStateChange } = useCollectionState(context);
|
const { onStateChange } = new UseCollectionStateBuilder()
|
||||||
|
.withContext(context)
|
||||||
|
.build();
|
||||||
|
|
||||||
// act
|
// act
|
||||||
onStateChange((newState) => {
|
onStateChange((newState) => {
|
||||||
@@ -159,21 +225,57 @@ describe('UseCollectionState', () => {
|
|||||||
});
|
});
|
||||||
it('call handler with old state after state changes', () => {
|
it('call handler with old state after state changes', () => {
|
||||||
// arrange
|
// arrange
|
||||||
const expected = new CategoryCollectionStateStub();
|
const expectedState = new CategoryCollectionStateStub();
|
||||||
let actual: IReadOnlyCategoryCollectionState;
|
let actualState: IReadOnlyCategoryCollectionState;
|
||||||
const context = new ApplicationContextStub();
|
const context = new ApplicationContextStub();
|
||||||
const { onStateChange } = useCollectionState(context);
|
const { onStateChange } = new UseCollectionStateBuilder()
|
||||||
|
.withContext(context)
|
||||||
|
.build();
|
||||||
|
|
||||||
// act
|
// act
|
||||||
onStateChange((_, oldState) => {
|
onStateChange((_, oldState) => {
|
||||||
actual = oldState;
|
actualState = oldState;
|
||||||
});
|
});
|
||||||
context.dispatchContextChange(
|
context.dispatchContextChange(
|
||||||
new ApplicationContextChangedEventStub().withOldState(expected),
|
new ApplicationContextChangedEventStub().withOldState(expectedState),
|
||||||
);
|
);
|
||||||
|
|
||||||
// assert
|
// 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
|
// arrange
|
||||||
const expectedError = 'missing state mutator';
|
const expectedError = 'missing state mutator';
|
||||||
const context = new ApplicationContextStub();
|
const context = new ApplicationContextStub();
|
||||||
const { modifyCurrentState } = useCollectionState(context);
|
const { modifyCurrentState } = new UseCollectionStateBuilder()
|
||||||
|
.withContext(context)
|
||||||
|
.build();
|
||||||
|
|
||||||
// act
|
// act
|
||||||
const act = () => modifyCurrentState(absentValue);
|
const act = () => modifyCurrentState(absentValue);
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
expect(act).to.throw(expectedError);
|
expect(act).to.throw(expectedError);
|
||||||
});
|
});
|
||||||
@@ -198,7 +304,9 @@ describe('UseCollectionState', () => {
|
|||||||
.withOs(oldOs);
|
.withOs(oldOs);
|
||||||
const context = new ApplicationContextStub()
|
const context = new ApplicationContextStub()
|
||||||
.withState(state);
|
.withState(state);
|
||||||
const { modifyCurrentState } = useCollectionState(context);
|
const { modifyCurrentState } = new UseCollectionStateBuilder()
|
||||||
|
.withContext(context)
|
||||||
|
.build();
|
||||||
|
|
||||||
// act
|
// act
|
||||||
modifyCurrentState((mutableState) => {
|
modifyCurrentState((mutableState) => {
|
||||||
@@ -217,8 +325,7 @@ describe('UseCollectionState', () => {
|
|||||||
itEachAbsentObjectValue((absentValue) => {
|
itEachAbsentObjectValue((absentValue) => {
|
||||||
// arrange
|
// arrange
|
||||||
const expectedError = 'missing context mutator';
|
const expectedError = 'missing context mutator';
|
||||||
const context = new ApplicationContextStub();
|
const { modifyCurrentContext } = new UseCollectionStateBuilder().build();
|
||||||
const { modifyCurrentContext } = useCollectionState(context);
|
|
||||||
// act
|
// act
|
||||||
const act = () => modifyCurrentContext(absentValue);
|
const act = () => modifyCurrentContext(absentValue);
|
||||||
// assert
|
// assert
|
||||||
@@ -233,7 +340,9 @@ describe('UseCollectionState', () => {
|
|||||||
.withOs(OperatingSystem.macOS);
|
.withOs(OperatingSystem.macOS);
|
||||||
const context = new ApplicationContextStub()
|
const context = new ApplicationContextStub()
|
||||||
.withState(oldState);
|
.withState(oldState);
|
||||||
const { modifyCurrentContext } = useCollectionState(context);
|
const { modifyCurrentContext } = new UseCollectionStateBuilder()
|
||||||
|
.withContext(context)
|
||||||
|
.build();
|
||||||
|
|
||||||
// act
|
// act
|
||||||
modifyCurrentContext((mutableContext) => {
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
24
tests/unit/presentation/injectionSymbols.ts
Normal file
24
tests/unit/presentation/injectionSymbols.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,14 +1,50 @@
|
|||||||
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
|
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
|
||||||
import { IEventSubscriptionCollection } from '@/infrastructure/Events/IEventSubscriptionCollection';
|
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>();
|
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);
|
this.subscriptions.push(...subscriptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
public unsubscribeAll() {
|
public unsubscribeAll(): void {
|
||||||
|
this.registerMethodCall({
|
||||||
|
methodName: 'unsubscribeAll',
|
||||||
|
args: [],
|
||||||
|
});
|
||||||
|
this.subscriptions.forEach((subscription) => subscription.unsubscribe());
|
||||||
this.subscriptions.length = 0;
|
this.subscriptions.length = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public unsubscribeAllAndRegister(
|
||||||
|
subscriptions: IEventSubscription[],
|
||||||
|
): void {
|
||||||
|
this.registerMethodCall({
|
||||||
|
methodName: 'unsubscribeAllAndRegister',
|
||||||
|
args: [subscriptions],
|
||||||
|
});
|
||||||
|
this.unsubscribeAll();
|
||||||
|
this.register(subscriptions);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
|
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
|
||||||
|
|
||||||
type UnsubscribeCallback = () => void;
|
|
||||||
|
|
||||||
export class EventSubscriptionStub implements IEventSubscription {
|
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>();
|
private readonly onUnsubscribe = new Array<UnsubscribeCallback>();
|
||||||
|
|
||||||
constructor(unsubscribeCallback?: UnsubscribeCallback) {
|
constructor(unsubscribeCallback?: UnsubscribeCallback) {
|
||||||
@@ -15,5 +23,13 @@ export class EventSubscriptionStub implements IEventSubscription {
|
|||||||
for (const callback of this.onUnsubscribe) {
|
for (const callback of this.onUnsubscribe) {
|
||||||
callback();
|
callback();
|
||||||
}
|
}
|
||||||
|
this.currentState = SubscriptionState.Unsubscribed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UnsubscribeCallback = () => void;
|
||||||
|
|
||||||
|
enum SubscriptionState {
|
||||||
|
Subscribed,
|
||||||
|
Unsubscribed,
|
||||||
|
}
|
||||||
|
|||||||
10
tests/unit/shared/Stubs/UseAutoUnsubscribedEventsStub.ts
Normal file
10
tests/unit/shared/Stubs/UseAutoUnsubscribedEventsStub.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
14
tests/unit/shared/Stubs/VueDependencyInjectionApiStub.ts
Normal file
14
tests/unit/shared/Stubs/VueDependencyInjectionApiStub.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,16 +3,18 @@ import { Constructible } from '@/TypeHelpers';
|
|||||||
|
|
||||||
interface ISingletonTestData<T> {
|
interface ISingletonTestData<T> {
|
||||||
getter: () => T;
|
getter: () => T;
|
||||||
expectedType: Constructible<T>;
|
expectedType?: Constructible<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function itIsSingleton<T>(test: ISingletonTestData<T>): void {
|
export function itIsSingleton<T>(test: ISingletonTestData<T>): void {
|
||||||
|
if (test.expectedType !== undefined) {
|
||||||
it('gets the expected type', () => {
|
it('gets the expected type', () => {
|
||||||
// act
|
// act
|
||||||
const instance = test.getter();
|
const instance = test.getter();
|
||||||
// assert
|
// assert
|
||||||
expect(instance).to.be.instanceOf(test.expectedType);
|
expect(instance).to.be.instanceOf(test.expectedType);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
it('multiple calls get the same instance', () => {
|
it('multiple calls get the same instance', () => {
|
||||||
// act
|
// act
|
||||||
const instance1 = test.getter();
|
const instance1 = test.getter();
|
||||||
|
|||||||
0
tests/unit/shared/TestCases/TransientFacto
Normal file
0
tests/unit/shared/TestCases/TransientFacto
Normal file
Reference in New Issue
Block a user