Refactor user selection state handling using hook

This commit introduces `useUserSelectionState` compositional hook. it
centralizes and allows reusing the logic for tracking and mutation user
selection state across the application.

The change aims to increase code reusability, simplify the code, improve
testability, and adhere to the single responsibility principle. It makes
the code more reliable against race conditions and removes the need for
tracking deep changes.

Other supporting changes:

- Introduce `CardStateIndicator` component for displaying the card state
  indicator icon, improving the testability and separation of concerns.
- Refactor `SelectionTypeHandler` to use functional code with more clear
  interfaces to simplify the code. It reduces complexity, increases
  maintainability and increase readability by explicitly separating
  mutating functions.
- Add new unit tests and extend improving ones to cover the new logic
  introduced in this commit. Remove the need to mount a wrapper
  component to simplify and optimize some tests, using parameter
  injection to inject dependencies intead.
This commit is contained in:
undergroundwires
2023-11-10 13:16:53 +01:00
parent 7770a9b521
commit 58cd551a30
22 changed files with 700 additions and 470 deletions

View File

@@ -5,5 +5,5 @@ export interface IEventSubscriptionCollection {
register(subscriptions: IEventSubscription[]): void;
unsubscribeAll(): void;
unsubscribeAllAndRegister(subscriptions: IEventSubscription[]);
unsubscribeAllAndRegister(subscriptions: IEventSubscription[]): void;
}

View File

@@ -11,6 +11,7 @@ import {
TransientKey, injectKey,
} from '@/presentation/injectionSymbols';
import { PropertyKeys } from '@/TypeHelpers';
import { useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
export function provideDependencies(
context: IApplicationContext,
@@ -48,6 +49,14 @@ export function provideDependencies(
return useCurrentCode(state, events);
},
),
useUserSelectionState: (di) => di.provide(
InjectionKeys.useUserSelectionState,
() => {
const events = di.injectKey((keys) => keys.useAutoUnsubscribedEvents);
const state = di.injectKey((keys) => keys.useCollectionState);
return useUserSelectionState(state, events);
},
),
};
registerAll(Object.values(resolvers), api);
}

View File

@@ -2,7 +2,8 @@ import { IScript } from '@/domain/IScript';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { RecommendationLevel } from '@/domain/RecommendationLevel';
import { scrambledEqual } from '@/application/Common/Array';
import { ICategoryCollectionState, IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { IReadOnlyUserSelection, IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
export enum SelectionType {
Standard,
@@ -12,66 +13,79 @@ export enum SelectionType {
Custom,
}
export class SelectionTypeHandler {
constructor(private readonly state: ICategoryCollectionState) {
if (!state) { throw new Error('missing state'); }
}
public selectType(type: SelectionType) {
export function setCurrentSelectionType(type: SelectionType, context: SelectionMutationContext) {
if (type === SelectionType.Custom) {
throw new Error('cannot select custom type');
}
const selector = selectors.get(type);
selector.select(this.state);
}
selector.select(context);
}
public getCurrentSelectionType(): SelectionType {
export function getCurrentSelectionType(context: SelectionCheckContext): SelectionType {
for (const [type, selector] of selectors.entries()) {
if (selector.isSelected(this.state)) {
if (selector.isSelected(context)) {
return type;
}
}
return SelectionType.Custom;
}
}
interface ISingleTypeHandler {
isSelected: (state: IReadOnlyCategoryCollectionState) => boolean;
select: (state: ICategoryCollectionState) => void;
export interface SelectionCheckContext {
readonly selection: IReadOnlyUserSelection;
readonly collection: ICategoryCollection;
}
const selectors = new Map<SelectionType, ISingleTypeHandler>([
export interface SelectionMutationContext {
readonly selection: IUserSelection,
readonly collection: ICategoryCollection,
}
interface SelectionTypeHandler {
isSelected: (context: SelectionCheckContext) => boolean;
select: (context: SelectionMutationContext) => void;
}
const selectors = new Map<SelectionType, SelectionTypeHandler>([
[SelectionType.None, {
select: (state) => state.selection.deselectAll(),
isSelected: (state) => state.selection.selectedScripts.length === 0,
select: ({ selection }) => selection.deselectAll(),
isSelected: ({ selection }) => selection.selectedScripts.length === 0,
}],
[SelectionType.Standard, getRecommendationLevelSelector(RecommendationLevel.Standard)],
[SelectionType.Strict, getRecommendationLevelSelector(RecommendationLevel.Strict)],
[SelectionType.All, {
select: (state) => state.selection.selectAll(),
isSelected: (state) => state.selection.selectedScripts.length === state.collection.totalScripts,
select: ({ selection }) => selection.selectAll(),
isSelected: (
{ selection, collection },
) => selection.selectedScripts.length === collection.totalScripts,
}],
]);
function getRecommendationLevelSelector(level: RecommendationLevel): ISingleTypeHandler {
function getRecommendationLevelSelector(
level: RecommendationLevel,
): SelectionTypeHandler {
return {
select: (state) => selectOnly(level, state),
isSelected: (state) => hasAllSelectedLevelOf(level, state),
select: (context) => selectOnly(level, context),
isSelected: (context) => hasAllSelectedLevelOf(level, context),
};
}
function hasAllSelectedLevelOf(
level: RecommendationLevel,
state: IReadOnlyCategoryCollectionState,
) {
const scripts = state.collection.getScriptsByLevel(level);
const { selectedScripts } = state.selection;
context: SelectionCheckContext,
): boolean {
const { collection, selection } = context;
const scripts = collection.getScriptsByLevel(level);
const { selectedScripts } = selection;
return areAllSelected(scripts, selectedScripts);
}
function selectOnly(level: RecommendationLevel, state: ICategoryCollectionState) {
const scripts = state.collection.getScriptsByLevel(level);
state.selection.selectOnly(scripts);
function selectOnly(
level: RecommendationLevel,
context: SelectionMutationContext,
): void {
const { collection, selection } = context;
const scripts = collection.getScriptsByLevel(level);
selection.selectOnly(scripts);
}
function areAllSelected(

View File

@@ -65,14 +65,15 @@
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue';
import {
defineComponent, computed,
} from 'vue';
import { injectKey } from '@/presentation/injectionSymbols';
import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import MenuOptionList from '../MenuOptionList.vue';
import MenuOptionListItem from '../MenuOptionListItem.vue';
import { SelectionType, SelectionTypeHandler } from './SelectionTypeHandler';
import { SelectionType, setCurrentSelectionType, getCurrentSelectionType } from './SelectionTypeHandler';
export default defineComponent({
components: {
@@ -81,43 +82,38 @@ export default defineComponent({
TooltipWrapper,
},
setup() {
const { modifyCurrentState, onStateChange } = injectKey((keys) => keys.useCollectionState);
const { events } = injectKey((keys) => keys.useAutoUnsubscribedEvents);
const {
currentSelection, modifyCurrentSelection,
} = injectKey((keys) => keys.useUserSelectionState);
const { currentState } = injectKey((keys) => keys.useCollectionState);
const currentSelection = ref(SelectionType.None);
const currentCollection = computed<ICategoryCollection>(() => currentState.value.collection);
let selectionTypeHandler: SelectionTypeHandler;
onStateChange(() => {
modifyCurrentState((state) => {
selectionTypeHandler = new SelectionTypeHandler(state);
updateSelections();
events.unsubscribeAllAndRegister([
subscribeAndUpdateSelections(state),
]);
const currentSelectionType = computed<SelectionType>({
get: () => getCurrentSelectionType({
selection: currentSelection.value,
collection: currentCollection.value,
}),
set: (type: SelectionType) => {
selectType(type);
},
});
}, { immediate: true });
function subscribeAndUpdateSelections(
state: ICategoryCollectionState,
): IEventSubscription {
return state.selection.changed.on(() => updateSelections());
}
function selectType(type: SelectionType) {
if (currentSelection.value === type) {
if (currentSelectionType.value === type) {
return;
}
selectionTypeHandler.selectType(type);
}
function updateSelections() {
currentSelection.value = selectionTypeHandler.getCurrentSelectionType();
modifyCurrentSelection((mutableSelection) => {
setCurrentSelectionType(type, {
selection: mutableSelection,
collection: currentCollection.value,
});
});
}
return {
SelectionType,
currentSelection,
currentSelection: currentSelectionType,
selectType,
};
},

View File

@@ -22,16 +22,10 @@
:icon="isExpanded ? 'folder-open' : 'folder'"
/>
<!-- Indeterminate and full states -->
<div class="card__inner__state-icons">
<AppIcon
icon="battery-half"
v-if="isAnyChildSelected && !areAllChildrenSelected"
<CardSelectionIndicator
class="card__inner__selection_indicator"
:categoryId="categoryId"
/>
<AppIcon
icon="battery-full"
v-if="areAllChildrenSelected"
/>
</div>
</div>
<div class="card__expander" v-on:click.stop>
<div class="card__expander__content">
@@ -49,17 +43,19 @@
<script lang="ts">
import {
defineComponent, ref, watch, computed, shallowRef,
defineComponent, computed, shallowRef,
} from 'vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import { injectKey } from '@/presentation/injectionSymbols';
import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue';
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
import CardSelectionIndicator from './CardSelectionIndicator.vue';
export default defineComponent({
components: {
ScriptsTree,
AppIcon,
CardSelectionIndicator,
},
props: {
categoryId: {
@@ -77,8 +73,7 @@ export default defineComponent({
/* eslint-enable @typescript-eslint/no-unused-vars */
},
setup(props, { emit }) {
const { onStateChange, currentState } = injectKey((keys) => keys.useCollectionState);
const { events } = injectKey((keys) => keys.useAutoUnsubscribedEvents);
const { currentState } = injectKey((keys) => keys.useCollectionState);
const isExpanded = computed({
get: () => {
@@ -92,8 +87,6 @@ export default defineComponent({
},
});
const isAnyChildSelected = ref(false);
const areAllChildrenSelected = ref(false);
const cardElement = shallowRef<HTMLElement>();
const cardTitle = computed<string | undefined>(() => {
@@ -108,37 +101,14 @@ export default defineComponent({
isExpanded.value = false;
}
onStateChange((state) => {
events.unsubscribeAllAndRegister([
state.selection.changed.on(
() => updateSelectionIndicators(props.categoryId),
),
]);
updateSelectionIndicators(props.categoryId);
}, { immediate: true });
watch(
() => props.categoryId,
(categoryId) => updateSelectionIndicators(categoryId),
);
async function scrollToCard() {
await sleep(400); // wait a bit to allow GUI to render the expanded card
cardElement.value.scrollIntoView({ behavior: 'smooth' });
}
function updateSelectionIndicators(categoryId: number) {
const category = currentState.value.collection.findCategory(categoryId);
const { selection } = currentState.value;
isAnyChildSelected.value = category ? selection.isAnySelected(category) : false;
areAllChildrenSelected.value = category ? selection.areAllSelected(category) : false;
}
return {
cardTitle,
isExpanded,
isAnyChildSelected,
areAllChildrenSelected,
cardElement,
collapse,
};
@@ -192,7 +162,7 @@ $card-horizontal-gap : $card-gap;
flex: 1;
justify-content: center;
}
&__state-icons {
&__selection_indicator {
height: $card-inner-padding;
margin-right: -$card-inner-padding;
padding-right: 10px;

View File

@@ -0,0 +1,55 @@
<template>
<div>
<AppIcon
icon="battery-half"
v-if="isAnyChildSelected && !areAllChildrenSelected"
/>
<AppIcon
icon="battery-full"
v-if="areAllChildrenSelected"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, computed } from 'vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import { injectKey } from '@/presentation/injectionSymbols';
import { ICategory } from '@/domain/ICategory';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
export default defineComponent({
components: {
AppIcon,
},
props: {
categoryId: {
type: Number,
required: true,
},
},
setup(props) {
const { currentState } = injectKey((keys) => keys.useCollectionState);
const { currentSelection } = injectKey((keys) => keys.useUserSelectionState);
const currentCollection = computed<ICategoryCollection>(() => currentState.value.collection);
const currentCategory = computed<ICategory>(
() => currentCollection.value.findCategory(props.categoryId),
);
const isAnyChildSelected = computed<boolean>(
() => currentSelection.value.isAnySelected(currentCategory.value),
);
const areAllChildrenSelected = computed<boolean>(
() => currentSelection.value.areAllSelected(currentCategory.value),
);
return {
isAnyChildSelected,
areAllChildrenSelected,
};
},
});
</script>

View File

@@ -1,6 +1,6 @@
<template>
<ToggleSwitch
v-model="isChecked"
v-model="isReverted"
:stopClickPropagation="true"
:label="'revert'"
/>
@@ -8,11 +8,11 @@
<script lang="ts">
import {
PropType, defineComponent, ref, watch, computed,
PropType, defineComponent, computed,
} from 'vue';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { injectKey } from '@/presentation/injectionSymbols';
import { NodeMetadata } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { IReverter } from './Reverter/IReverter';
import { getReverter } from './Reverter/ReverterFactory';
import ToggleSwitch from './ToggleSwitch.vue';
@@ -29,56 +29,37 @@ export default defineComponent({
},
setup(props) {
const {
currentState, modifyCurrentState, onStateChange,
} = injectKey((keys) => keys.useCollectionState);
const { events } = injectKey((keys) => keys.useAutoUnsubscribedEvents);
currentSelection, modifyCurrentSelection,
} = injectKey((keys) => keys.useUserSelectionState);
const { currentState } = injectKey((keys) => keys.useCollectionState);
const isReverted = ref(false);
const currentCollection = computed<ICategoryCollection>(() => currentState.value.collection);
let handler: IReverter | undefined;
watch(
() => props.node,
(node) => onNodeChanged(node),
{ immediate: true },
const revertHandler = computed<IReverter>(
() => getReverter(props.node, currentCollection.value),
);
onStateChange((newState) => {
updateRevertStatusFromState(newState.selection.selectedScripts);
events.unsubscribeAllAndRegister([
newState.selection.changed.on((scripts) => updateRevertStatusFromState(scripts)),
]);
}, { immediate: true });
function onNodeChanged(node: NodeMetadata) {
handler = getReverter(node, currentState.value.collection);
updateRevertStatusFromState(currentState.value.selection.selectedScripts);
}
function updateRevertStatusFromState(scripts: ReadonlyArray<SelectedScript>) {
isReverted.value = handler?.getState(scripts) ?? false;
}
function syncReversionStatusWithState(value: boolean) {
if (value === isReverted.value) {
return;
}
modifyCurrentState((state) => {
handler.selectWithRevertState(value, state.selection);
});
}
const isChecked = computed({
const isReverted = computed<boolean>({
get() {
return isReverted.value;
const { selectedScripts } = currentSelection.value;
return revertHandler.value.getState(selectedScripts);
},
set: (value: boolean) => {
syncReversionStatusWithState(value);
},
});
function syncReversionStatusWithState(value: boolean) {
if (value === isReverted.value) {
return;
}
modifyCurrentSelection((mutableSelection) => {
revertHandler.value.selectWithRevertState(value, mutableSelection);
});
}
return {
isChecked,
isReverted,
};
},
});

View File

@@ -18,6 +18,7 @@
<script lang="ts">
import { defineComponent } from 'vue';
import { injectKey } from '@/presentation/injectionSymbols';
import TreeView from './TreeView/TreeView.vue';
import NodeContent from './NodeContent/NodeContent.vue';
import { useTreeViewFilterEvent } from './TreeViewAdapter/UseTreeViewFilterEvent';
@@ -38,10 +39,11 @@ export default defineComponent({
NodeContent,
},
setup(props) {
const { selectedScriptNodeIds } = useSelectedScriptNodeIds();
const useUserCollectionStateHook = injectKey((keys) => keys.useUserSelectionState);
const { selectedScriptNodeIds } = useSelectedScriptNodeIds(useUserCollectionStateHook);
const { latestFilterEvent } = useTreeViewFilterEvent();
const { treeViewInputNodes } = useTreeViewNodeInput(() => props.categoryId);
const { updateNodeSelection } = useCollectionSelectionStateUpdater();
const { updateNodeSelection } = useCollectionSelectionStateUpdater(useUserCollectionStateHook);
function handleNodeChangedEvent(event: TreeNodeStateChangedEmittedEvent) {
updateNodeSelection(event);

View File

@@ -1,9 +1,11 @@
import { injectKey } from '@/presentation/injectionSymbols';
import { useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
import { TreeNodeCheckState } from '../TreeView/Node/State/CheckState';
import { TreeNodeStateChangedEmittedEvent } from '../TreeView/Bindings/TreeNodeStateChangedEmittedEvent';
export function useCollectionSelectionStateUpdater() {
const { modifyCurrentState, currentState } = injectKey((keys) => keys.useCollectionState);
export function useCollectionSelectionStateUpdater(
useSelectionStateHook: ReturnType<typeof useUserSelectionState>,
) {
const { modifyCurrentSelection, currentSelection } = useSelectionStateHook;
function updateNodeSelection(change: TreeNodeStateChangedEmittedEvent) {
const { node } = change;
@@ -14,19 +16,19 @@ export function useCollectionSelectionStateUpdater() {
return;
}
if (node.state.current.checkState === TreeNodeCheckState.Checked) {
if (currentState.value.selection.isSelected(node.id)) {
if (currentSelection.value.isSelected(node.id)) {
return;
}
modifyCurrentState((state) => {
state.selection.addSelectedScript(node.id, false);
modifyCurrentSelection((selection) => {
selection.addSelectedScript(node.id, false);
});
}
if (node.state.current.checkState === TreeNodeCheckState.Unchecked) {
if (!currentState.value.selection.isSelected(node.id)) {
if (!currentSelection.value.isSelected(node.id)) {
return;
}
modifyCurrentState((state) => {
state.selection.removeSelectedScript(node.id);
modifyCurrentSelection((selection) => {
selection.removeSelectedScript(node.id);
});
}
}

View File

@@ -1,16 +1,19 @@
import {
computed, shallowReadonly, shallowRef, triggerRef,
computed, shallowReadonly,
} from 'vue';
import { injectKey } from '@/presentation/injectionSymbols';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import type { useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
import { getScriptNodeId } from './CategoryNodeMetadataConverter';
export function useSelectedScriptNodeIds(scriptNodeIdParser = getScriptNodeId) {
const { selectedScripts } = useSelectedScripts();
export function useSelectedScriptNodeIds(
useSelectionStateHook: ReturnType<typeof useUserSelectionState>,
scriptNodeIdParser = getScriptNodeId,
) {
const { currentSelection } = useSelectionStateHook;
const selectedNodeIds = computed<readonly string[]>(() => {
return selectedScripts
return currentSelection
.value
.selectedScripts
.map((selected) => scriptNodeIdParser(selected.script));
});
@@ -18,33 +21,3 @@ export function useSelectedScriptNodeIds(scriptNodeIdParser = getScriptNodeId) {
selectedScriptNodeIds: shallowReadonly(selectedNodeIds),
};
}
function useSelectedScripts() {
const { events } = injectKey((keys) => keys.useAutoUnsubscribedEvents);
const { onStateChange } = injectKey((keys) => keys.useCollectionState);
const selectedScripts = shallowRef<readonly SelectedScript[]>([]);
function updateSelectedScripts(newReference: readonly SelectedScript[]) {
if (selectedScripts.value === newReference) {
// Manually trigger update if the array was mutated using the same reference.
// Array might have been mutated without changing the reference
triggerRef(selectedScripts);
} else {
selectedScripts.value = newReference;
}
}
onStateChange((state) => {
updateSelectedScripts(state.selection.selectedScripts);
events.unsubscribeAllAndRegister([
state.selection.changed.on((scripts) => {
updateSelectedScripts(scripts);
}),
]);
}, { immediate: true });
return {
selectedScripts: shallowReadonly(selectedScripts),
};
}

View File

@@ -0,0 +1,48 @@
import { shallowReadonly, shallowRef, triggerRef } from 'vue';
import { IReadOnlyUserSelection, IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import type { useAutoUnsubscribedEvents } from './UseAutoUnsubscribedEvents';
import type { useCollectionState } from './UseCollectionState';
export function useUserSelectionState(
collectionState: ReturnType<typeof useCollectionState>,
autoUnsubscribedEvents: ReturnType<typeof useAutoUnsubscribedEvents>,
) {
const { events } = autoUnsubscribedEvents;
const { onStateChange, modifyCurrentState, currentState } = collectionState;
const currentSelection = shallowRef<IReadOnlyUserSelection>(currentState.value.selection);
onStateChange((state) => {
updateSelection(state.selection);
events.unsubscribeAllAndRegister([
state.selection.changed.on(() => {
updateSelection(state.selection);
}),
]);
}, { immediate: true });
function modifyCurrentSelection(mutator: SelectionModifier) {
modifyCurrentState((state) => {
mutator(state.selection);
});
}
function updateSelection(newSelection: IReadOnlyUserSelection) {
if (currentSelection.value === newSelection) {
// Do not trust Vue tracking, the changed selection object
// reference may stay same for same collection.
triggerRef(currentSelection);
} else {
currentSelection.value = newSelection;
}
}
return {
currentSelection: shallowReadonly(currentSelection),
modifyCurrentSelection,
};
}
export type SelectionModifier = (
state: IUserSelection,
) => void;

View File

@@ -5,6 +5,7 @@ import type { useRuntimeEnvironment } from '@/presentation/components/Shared/Hoo
import type { useClipboard } from '@/presentation/components/Shared/Hooks/Clipboard/UseClipboard';
import type { useCurrentCode } from '@/presentation/components/Shared/Hooks/UseCurrentCode';
import type { useAutoUnsubscribedEvents } from '@/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents';
import type { useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
export const InjectionKeys = {
useCollectionState: defineTransientKey<ReturnType<typeof useCollectionState>>('useCollectionState'),
@@ -13,6 +14,7 @@ export const InjectionKeys = {
useAutoUnsubscribedEvents: defineTransientKey<ReturnType<typeof useAutoUnsubscribedEvents>>('useAutoUnsubscribedEvents'),
useClipboard: defineTransientKey<ReturnType<typeof useClipboard>>('useClipboard'),
useCurrentCode: defineTransientKey<ReturnType<typeof useCurrentCode>>('useCurrentCode'),
useUserSelectionState: defineTransientKey<ReturnType<typeof useUserSelectionState>>('useUserSelectionState'),
};
export interface InjectionKeyWithLifetime<T> {

View File

@@ -16,6 +16,7 @@ describe('DependencyProvider', () => {
useAutoUnsubscribedEvents: createTransientTests(),
useClipboard: createTransientTests(),
useCurrentCode: createTransientTests(),
useUserSelectionState: createTransientTests(),
};
Object.entries(testCases).forEach(([key, runTests]) => {
const registeredKey = InjectionKeys[key].key;

View File

@@ -1,33 +1,22 @@
import { describe, it, expect } from 'vitest';
import { SelectionType, SelectionTypeHandler } from '@/presentation/components/Scripts/Menu/Selector/SelectionTypeHandler';
import {
SelectionCheckContext, SelectionMutationContext, SelectionType,
getCurrentSelectionType, setCurrentSelectionType,
} from '@/presentation/components/Scripts/Menu/Selector/SelectionTypeHandler';
import { scrambledEqual } from '@/application/Common/Array';
import { RecommendationLevel } from '@/domain/RecommendationLevel';
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { SelectionStateTestScenario } from './SelectionStateTestScenario';
describe('SelectionTypeHandler', () => {
describe('ctor', () => {
describe('throws when state is missing', () => {
itEachAbsentObjectValue((absentValue) => {
// arrange
const expectedError = 'missing state';
const state = absentValue;
// act
const sut = () => new SelectionTypeHandler(state);
// assert
expect(sut).to.throw(expectedError);
});
});
});
describe('selectType', () => {
describe('setCurrentSelectionType', () => {
it('throws when type is custom', () => {
// arrange
const expectedError = 'cannot select custom type';
const scenario = new SelectionStateTestScenario();
const state = scenario.generateState([]);
const sut = new SelectionTypeHandler(state);
// act
const act = () => sut.selectType(SelectionType.Custom);
const act = () => setCurrentSelectionType(SelectionType.Custom, createMutationContext(state));
// assert
expect(act).to.throw(expectedError);
});
@@ -47,7 +36,6 @@ describe('SelectionTypeHandler', () => {
for (const initialScriptsCase of initialScriptsCases) {
describe(initialScriptsCase.name, () => {
const state = scenario.generateState(initialScriptsCase.initialScripts);
const sut = new SelectionTypeHandler(state);
const typeExpectations = [{
input: SelectionType.None,
output: [],
@@ -64,7 +52,7 @@ describe('SelectionTypeHandler', () => {
for (const expectation of typeExpectations) {
// act
it(`${SelectionType[expectation.input]} returns as expected`, () => {
sut.selectType(expectation.input);
setCurrentSelectionType(expectation.input, createMutationContext(state));
// assert
const actual = state.selection.selectedScripts;
const expected = expectation.output;
@@ -114,9 +102,8 @@ describe('SelectionTypeHandler', () => {
for (const testCase of testCases) {
it(testCase.name, () => {
const state = scenario.generateState(testCase.selection);
const sut = new SelectionTypeHandler(state);
// act
const actual = sut.getCurrentSelectionType();
const actual = getCurrentSelectionType(createCheckContext(state));
// assert
expect(actual).to.deep.equal(
testCase.expected,
@@ -135,3 +122,17 @@ describe('SelectionTypeHandler', () => {
}
});
});
function createMutationContext(state: ICategoryCollectionState): SelectionMutationContext {
return {
selection: state.selection,
collection: state.collection,
};
}
function createCheckContext(state: ICategoryCollectionState): SelectionCheckContext {
return {
selection: state.selection,
collection: state.collection,
};
}

View File

@@ -1,22 +1,19 @@
import { describe, it, expect } from 'vitest';
import { shallowMount } from '@vue/test-utils';
import { useCollectionSelectionStateUpdater } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseCollectionSelectionStateUpdater';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { HierarchyAccessStub } from '@tests/unit/shared/Stubs/HierarchyAccessStub';
import { TreeNodeStateAccessStub } from '@tests/unit/shared/Stubs/TreeNodeStateAccessStub';
import { TreeNodeStub } from '@tests/unit/shared/Stubs/TreeNodeStub';
import { UseCollectionStateStub } from '@tests/unit/shared/Stubs/UseCollectionStateStub';
import { TreeNodeCheckState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/CheckState';
import { CategoryCollectionStateStub } from '@tests/unit/shared/Stubs/CategoryCollectionStateStub';
import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub';
import { TreeNodeStateChangedEmittedEventStub } from '@tests/unit/shared/Stubs/TreeNodeStateChangedEmittedEventStub';
import { UseUserSelectionStateStub } from '@tests/unit/shared/Stubs/UseUserSelectionStateStub';
describe('useCollectionSelectionStateUpdater', () => {
describe('updateNodeSelection', () => {
describe('when node is a branch node', () => {
it('does nothing', () => {
// arrange
const { returnObject, useStateStub } = mountWrapperComponent();
const { returnObject, useSelectionStateStub } = runHook();
const mockEvent = new TreeNodeStateChangedEmittedEventStub()
.withNode(
createTreeNodeStub({
@@ -31,14 +28,13 @@ describe('useCollectionSelectionStateUpdater', () => {
// act
returnObject.updateNodeSelection(mockEvent);
// assert
const modifyCall = useStateStub.callHistory.find((call) => call.methodName === 'modifyCurrentState');
expect(modifyCall).toBeUndefined();
expect(useSelectionStateStub.isSelectionModified()).to.equal(false);
});
});
describe('when old and new check states are the same', () => {
it('does nothing', () => {
// arrange
const { returnObject, useStateStub } = mountWrapperComponent();
const { returnObject, useSelectionStateStub } = runHook();
const mockEvent = new TreeNodeStateChangedEmittedEventStub()
.withNode(
createTreeNodeStub({
@@ -53,24 +49,22 @@ describe('useCollectionSelectionStateUpdater', () => {
// act
returnObject.updateNodeSelection(mockEvent);
// assert
const modifyCall = useStateStub.callHistory.find((call) => call.methodName === 'modifyCurrentState');
expect(modifyCall).toBeUndefined();
expect(useSelectionStateStub.isSelectionModified()).to.equal(false);
});
});
describe('when checkState is checked', () => {
it('adds to selection if not already selected', () => {
// arrange
const { returnObject, useStateStub } = mountWrapperComponent();
const { returnObject, useSelectionStateStub } = runHook();
const selectionStub = new UserSelectionStub([]);
selectionStub.isSelected = () => false;
useStateStub.withState(new CategoryCollectionStateStub().withSelection(selectionStub));
const mockEvent = new TreeNodeStateChangedEmittedEventStub()
.withNode(
createTreeNodeStub({
useSelectionStateStub.withUserSelection(selectionStub);
const node = createTreeNodeStub({
isBranch: false,
currentState: TreeNodeCheckState.Checked,
}),
)
});
const mockEvent = new TreeNodeStateChangedEmittedEventStub()
.withNode(node)
.withCheckStateChange({
oldState: TreeNodeCheckState.Unchecked,
newState: TreeNodeCheckState.Checked,
@@ -78,17 +72,15 @@ describe('useCollectionSelectionStateUpdater', () => {
// act
returnObject.updateNodeSelection(mockEvent);
// assert
const modifyCall = useStateStub.callHistory.find((call) => call.methodName === 'modifyCurrentState');
expect(modifyCall).toBeDefined();
const addSelectedScriptCall = selectionStub.callHistory.find((call) => call.methodName === 'addSelectedScript');
expect(addSelectedScriptCall).toBeDefined();
expect(useSelectionStateStub.isSelectionModified()).to.equal(true);
expect(selectionStub.isScriptAdded(node.id)).to.equal(true);
});
it('does nothing if already selected', () => {
// arrange
const { returnObject, useStateStub } = mountWrapperComponent();
const { returnObject, useSelectionStateStub } = runHook();
const selectionStub = new UserSelectionStub([]);
selectionStub.isSelected = () => true;
useStateStub.withState(new CategoryCollectionStateStub().withSelection(selectionStub));
useSelectionStateStub.withUserSelection(selectionStub);
const mockEvent = new TreeNodeStateChangedEmittedEventStub()
.withNode(
createTreeNodeStub({
@@ -103,24 +95,22 @@ describe('useCollectionSelectionStateUpdater', () => {
// act
returnObject.updateNodeSelection(mockEvent);
// assert
const modifyCall = useStateStub.callHistory.find((call) => call.methodName === 'modifyCurrentState');
expect(modifyCall).toBeUndefined();
expect(useSelectionStateStub.isSelectionModified()).to.equal(false);
});
});
describe('when checkState is unchecked', () => {
it('removes from selection if already selected', () => {
// arrange
const { returnObject, useStateStub } = mountWrapperComponent();
const { returnObject, useSelectionStateStub } = runHook();
const selectionStub = new UserSelectionStub([]);
selectionStub.isSelected = () => true;
useStateStub.withState(new CategoryCollectionStateStub().withSelection(selectionStub));
const mockEvent = new TreeNodeStateChangedEmittedEventStub()
.withNode(
createTreeNodeStub({
useSelectionStateStub.withUserSelection(selectionStub);
const node = createTreeNodeStub({
isBranch: false,
currentState: TreeNodeCheckState.Unchecked,
}),
)
});
const mockEvent = new TreeNodeStateChangedEmittedEventStub()
.withNode(node)
.withCheckStateChange({
oldState: TreeNodeCheckState.Checked,
newState: TreeNodeCheckState.Unchecked,
@@ -128,17 +118,15 @@ describe('useCollectionSelectionStateUpdater', () => {
// act
returnObject.updateNodeSelection(mockEvent);
// assert
const modifyCall = useStateStub.callHistory.find((call) => call.methodName === 'modifyCurrentState');
expect(modifyCall).toBeDefined();
const removeSelectedScriptCall = selectionStub.callHistory.find((call) => call.methodName === 'removeSelectedScript');
expect(removeSelectedScriptCall).toBeDefined();
expect(useSelectionStateStub.isSelectionModified()).to.equal(true);
expect(selectionStub.isScriptRemoved(node.id)).to.equal(true);
});
it('does nothing if not already selected', () => {
// arrange
const { returnObject, useStateStub } = mountWrapperComponent();
const { returnObject, useSelectionStateStub } = runHook();
const selectionStub = new UserSelectionStub([]);
selectionStub.isSelected = () => false;
useStateStub.withState(new CategoryCollectionStateStub().withSelection(selectionStub));
useSelectionStateStub.withUserSelection(selectionStub);
const mockEvent = new TreeNodeStateChangedEmittedEventStub()
.withNode(
createTreeNodeStub({
@@ -153,33 +141,18 @@ describe('useCollectionSelectionStateUpdater', () => {
// act
returnObject.updateNodeSelection(mockEvent);
// assert
const modifyCall = useStateStub.callHistory.find((call) => call.methodName === 'modifyCurrentState');
expect(modifyCall).toBeUndefined();
expect(useSelectionStateStub.isSelectionModified()).to.equal(false);
});
});
});
});
function mountWrapperComponent() {
const useStateStub = new UseCollectionStateStub();
let returnObject: ReturnType<typeof useCollectionSelectionStateUpdater>;
shallowMount({
setup() {
returnObject = useCollectionSelectionStateUpdater();
},
template: '<div></div>',
}, {
global: {
provide: {
[InjectionKeys.useCollectionState.key]: () => useStateStub.get(),
},
},
});
function runHook() {
const useSelectionStateStub = new UseUserSelectionStateStub();
const returnObject = useCollectionSelectionStateUpdater(useSelectionStateStub.get());
return {
returnObject,
useStateStub,
useSelectionStateStub,
};
}

View File

@@ -1,39 +1,20 @@
import { describe, it, expect } from 'vitest';
import { shallowMount } from '@vue/test-utils';
import { nextTick, watch } from 'vue';
import { useSelectedScriptNodeIds } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseSelectedScriptNodeIds';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { UseAutoUnsubscribedEventsStub } from '@tests/unit/shared/Stubs/UseAutoUnsubscribedEventsStub';
import { UseCollectionStateStub } from '@tests/unit/shared/Stubs/UseCollectionStateStub';
import { CategoryCollectionStateStub } from '@tests/unit/shared/Stubs/CategoryCollectionStateStub';
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
import { getScriptNodeId } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter';
import { IScript } from '@/domain/IScript';
import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub';
import { UseUserSelectionStateStub } from '@tests/unit/shared/Stubs/UseUserSelectionStateStub';
describe('useSelectedScriptNodeIds', () => {
it('returns an empty array when no scripts are selected', () => {
// arrange
const { useStateStub, returnObject } = mountWrapperComponent();
useStateStub.withState(new CategoryCollectionStateStub().withSelectedScripts([]));
const { useSelectionStateStub, returnObject } = runHook();
useSelectionStateStub.withSelectedScripts([]);
// act
const actualIds = returnObject.selectedScriptNodeIds.value;
// assert
expect(actualIds).to.have.lengthOf(0);
});
it('initially registers the unsubscribe callback', () => {
// arrange
const eventsStub = new UseAutoUnsubscribedEventsStub();
// act
mountWrapperComponent({
useAutoUnsubscribedEvents: eventsStub,
});
// assert
const calls = eventsStub.events.callHistory;
expect(eventsStub.events.callHistory).has.lengthOf(1);
const call = calls.find((c) => c.methodName === 'unsubscribeAllAndRegister');
expect(call).toBeDefined();
});
describe('returns correct node IDs for selected scripts', () => {
it('immediately', () => {
// arrange
@@ -45,12 +26,11 @@ describe('useSelectedScriptNodeIds', () => {
[selectedScripts[0].script, 'expected-id-1'],
[selectedScripts[1].script, 'expected-id-2'],
]);
const { useStateStub, returnObject } = mountWrapperComponent({
const useSelectionStateStub = new UseUserSelectionStateStub()
.withSelectedScripts(selectedScripts);
const { returnObject } = runHook({
scriptNodeIdParser: createNodeIdParserFromMap(parsedNodeIds),
});
useStateStub.triggerOnStateChange({
newState: new CategoryCollectionStateStub().withSelectedScripts(selectedScripts),
immediateOnly: true,
useSelectionState: useSelectionStateStub,
});
// act
const actualIds = returnObject.selectedScriptNodeIds.value;
@@ -59,35 +39,6 @@ describe('useSelectedScriptNodeIds', () => {
expect(actualIds).to.have.lengthOf(expectedNodeIds.length);
expect(actualIds).to.include.members(expectedNodeIds);
});
it('when the collection state changes', () => {
// arrange
const initialScripts = [];
const changedScripts = [
new SelectedScriptStub('id-1'),
new SelectedScriptStub('id-2'),
];
const parsedNodeIds = new Map<IScript, string>([
[changedScripts[0].script, 'expected-id-1'],
[changedScripts[1].script, 'expected-id-2'],
]);
const { useStateStub, returnObject } = mountWrapperComponent({
scriptNodeIdParser: createNodeIdParserFromMap(parsedNodeIds),
});
useStateStub.triggerOnStateChange({
newState: new CategoryCollectionStateStub().withSelectedScripts(initialScripts),
immediateOnly: true,
});
// act
useStateStub.triggerOnStateChange({
newState: new CategoryCollectionStateStub().withSelectedScripts(changedScripts),
immediateOnly: false,
});
const actualIds = returnObject.selectedScriptNodeIds.value;
// assert
const expectedNodeIds = [...parsedNodeIds.values()];
expect(actualIds).to.have.lengthOf(expectedNodeIds.length);
expect(actualIds).to.include.members(expectedNodeIds);
});
it('when the selection state changes', () => {
// arrange
const initialScripts = [];
@@ -99,18 +50,14 @@ describe('useSelectedScriptNodeIds', () => {
[changedScripts[0].script, 'expected-id-1'],
[changedScripts[1].script, 'expected-id-2'],
]);
const { useStateStub, returnObject } = mountWrapperComponent({
scriptNodeIdParser: createNodeIdParserFromMap(parsedNodeIds),
});
const userSelection = new UserSelectionStub([])
const useSelectionStateStub = new UseUserSelectionStateStub()
.withSelectedScripts(initialScripts);
useStateStub.triggerOnStateChange({
newState: new CategoryCollectionStateStub()
.withSelection(userSelection),
immediateOnly: true,
const { returnObject } = runHook({
scriptNodeIdParser: createNodeIdParserFromMap(parsedNodeIds),
useSelectionState: useSelectionStateStub,
});
// act
userSelection.triggerSelectionChangedEvent(changedScripts);
useSelectionStateStub.withSelectedScripts(changedScripts);
const actualIds = returnObject.selectedScriptNodeIds.value;
// assert
const expectedNodeIds = [...parsedNodeIds.values()];
@@ -118,96 +65,6 @@ describe('useSelectedScriptNodeIds', () => {
expect(actualIds).to.include.members(expectedNodeIds);
});
});
describe('reactivity to state changes', () => {
describe('when the collection state changes', () => {
it('with new array references', async () => {
// arrange
const { useStateStub, returnObject } = mountWrapperComponent();
let isChangeTriggered = false;
watch(() => returnObject.selectedScriptNodeIds.value, () => {
isChangeTriggered = true;
});
// act
useStateStub.triggerOnStateChange({
newState: new CategoryCollectionStateStub(),
immediateOnly: false,
});
await nextTick();
// assert
expect(isChangeTriggered).to.equal(true);
});
it('with the same array reference', async () => {
// arrange
const sharedSelectedScriptsReference = [];
const initialCollectionState = new CategoryCollectionStateStub()
.withSelectedScripts(sharedSelectedScriptsReference);
const changedCollectionState = new CategoryCollectionStateStub()
.withSelectedScripts(sharedSelectedScriptsReference);
const { useStateStub, returnObject } = mountWrapperComponent();
useStateStub.triggerOnStateChange({
newState: initialCollectionState,
immediateOnly: true,
});
let isChangeTriggered = false;
watch(() => returnObject.selectedScriptNodeIds.value, () => {
isChangeTriggered = true;
});
// act
sharedSelectedScriptsReference.push(new SelectedScriptStub('new')); // mutate array using same reference
useStateStub.triggerOnStateChange({
newState: changedCollectionState,
immediateOnly: false,
});
await nextTick();
// assert
expect(isChangeTriggered).to.equal(true);
});
});
describe('when the selection state changes', () => {
it('with new array references', async () => {
// arrange
const { useStateStub, returnObject } = mountWrapperComponent();
const userSelection = new UserSelectionStub([])
.withSelectedScripts([]);
useStateStub.triggerOnStateChange({
newState: new CategoryCollectionStateStub()
.withSelection(userSelection),
immediateOnly: true,
});
let isChangeTriggered = false;
watch(() => returnObject.selectedScriptNodeIds.value, () => {
isChangeTriggered = true;
});
// act
userSelection.triggerSelectionChangedEvent([]);
await nextTick();
// assert
expect(isChangeTriggered).to.equal(true);
});
it('with the same array reference', async () => {
// arrange
const { useStateStub, returnObject } = mountWrapperComponent();
const sharedSelectedScriptsReference = [];
const userSelection = new UserSelectionStub([])
.withSelectedScripts(sharedSelectedScriptsReference);
useStateStub.triggerOnStateChange({
newState: new CategoryCollectionStateStub()
.withSelection(userSelection),
immediateOnly: true,
});
let isChangeTriggered = false;
watch(() => returnObject.selectedScriptNodeIds.value, () => {
isChangeTriggered = true;
});
// act
sharedSelectedScriptsReference.push(new SelectedScriptStub('new')); // mutate array using same reference
userSelection.triggerSelectionChangedEvent(sharedSelectedScriptsReference);
await nextTick();
// assert
expect(isChangeTriggered).to.equal(true);
});
});
});
});
type ScriptNodeIdParser = typeof getScriptNodeId;
@@ -222,33 +79,16 @@ function createNodeIdParserFromMap(scriptToIdMap: Map<IScript, string>): ScriptN
};
}
function mountWrapperComponent(scenario?: {
function runHook(scenario?: {
readonly scriptNodeIdParser?: ScriptNodeIdParser,
readonly useAutoUnsubscribedEvents?: UseAutoUnsubscribedEventsStub,
readonly useSelectionState?: UseUserSelectionStateStub,
}) {
const useStateStub = new UseCollectionStateStub();
const useSelectionStateStub = scenario?.useSelectionState ?? new UseUserSelectionStateStub();
const nodeIdParser: ScriptNodeIdParser = scenario?.scriptNodeIdParser
?? ((script) => script.id);
let returnObject: ReturnType<typeof useSelectedScriptNodeIds>;
shallowMount({
setup() {
returnObject = useSelectedScriptNodeIds(nodeIdParser);
},
template: '<div></div>',
}, {
global: {
provide: {
[InjectionKeys.useCollectionState.key]:
() => useStateStub.get(),
[InjectionKeys.useAutoUnsubscribedEvents.key]:
() => (scenario?.useAutoUnsubscribedEvents ?? new UseAutoUnsubscribedEventsStub()).get(),
},
},
});
const returnObject = useSelectedScriptNodeIds(useSelectionStateStub.get(), nodeIdParser);
return {
returnObject,
useStateStub,
useSelectionStateStub,
};
}

View File

@@ -66,7 +66,7 @@ describe('UseTreeViewFilterEvent', () => {
.withFilter(filterStub);
const { returnObject } = mountWrapperComponent({ useStateStub: stateStub });
let totalFilterUpdates = 0;
watch(() => returnObject.latestFilterEvent.value, () => {
watch(returnObject.latestFilterEvent, () => {
totalFilterUpdates++;
});
// act

View File

@@ -0,0 +1,273 @@
import { describe, it, expect } from 'vitest';
import { nextTick, watch } from 'vue';
import { SelectionModifier, useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
import { UseCollectionStateStub } from '@tests/unit/shared/Stubs/UseCollectionStateStub';
import { UseAutoUnsubscribedEventsStub } from '@tests/unit/shared/Stubs/UseAutoUnsubscribedEventsStub';
import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { CategoryCollectionStateStub } from '@tests/unit/shared/Stubs/CategoryCollectionStateStub';
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
describe('useUserSelectionState', () => {
describe('currentSelection', () => {
it('initializes with correct selection', () => {
// arrange
const expectedSelection = new UserSelectionStub([new ScriptStub('initialSelectedScript')]);
const collectionStateStub = new UseCollectionStateStub()
.withState(new CategoryCollectionStateStub().withSelection(expectedSelection));
// act
const { returnObject } = runHook({
useCollectionState: collectionStateStub,
});
// assert
const actualSelection = returnObject.currentSelection.value;
expect(actualSelection).to.equal(expectedSelection);
});
describe('once collection state is changed', () => {
it('updated', () => {
// arrange
const initialSelection = new UserSelectionStub([new ScriptStub('initialSelectedScript')]);
const changedSelection = new UserSelectionStub([new ScriptStub('changedSelectedScript')]);
const collectionStateStub = new UseCollectionStateStub()
.withState(new CategoryCollectionStateStub().withSelection(initialSelection));
const { returnObject } = runHook({
useCollectionState: collectionStateStub,
});
// act
collectionStateStub.triggerOnStateChange({
newState: new CategoryCollectionStateStub().withSelection(changedSelection),
immediateOnly: false,
});
// assert
const actualSelection = returnObject.currentSelection.value;
expect(actualSelection).to.equal(changedSelection);
});
it('not updated when old state changes', async () => {
// arrange
const oldSelectionState = new UserSelectionStub([new ScriptStub('inOldState')]);
const newSelectionState = new UserSelectionStub([new ScriptStub('inNewState')]);
const collectionStateStub = new UseCollectionStateStub()
.withState(new CategoryCollectionStateStub().withSelection(oldSelectionState));
const { returnObject } = runHook({
useCollectionState: collectionStateStub,
});
collectionStateStub.triggerOnStateChange({
newState: new CategoryCollectionStateStub().withSelection(newSelectionState),
immediateOnly: false,
});
let totalUpdates = 0;
watch(returnObject.currentSelection, () => {
totalUpdates++;
});
// act
oldSelectionState.triggerSelectionChangedEvent([new SelectedScriptStub('newInOldState')]);
await nextTick();
// assert
expect(totalUpdates).to.equal(0);
});
describe('triggers change', () => {
it('with new selection reference', async () => {
// arrange
const oldSelection = new UserSelectionStub([]);
const newSelection = new UserSelectionStub([]);
const initialCollectionState = new CategoryCollectionStateStub()
.withSelection(oldSelection);
const changedCollectionState = new CategoryCollectionStateStub()
.withSelection(newSelection);
const collectionStateStub = new UseCollectionStateStub()
.withState(initialCollectionState);
const { returnObject } = runHook({ useCollectionState: collectionStateStub });
let isChangeTriggered = false;
watch(returnObject.currentSelection, () => {
isChangeTriggered = true;
});
// act
collectionStateStub.triggerOnStateChange({
newState: changedCollectionState,
immediateOnly: false,
});
await nextTick();
// assert
expect(isChangeTriggered).to.equal(true);
});
it('with the same selection reference', async () => {
// arrange
const userSelection = new UserSelectionStub([new ScriptStub('sameScriptInSameReference')]);
const initialCollectionState = new CategoryCollectionStateStub()
.withSelection(userSelection);
const changedCollectionState = new CategoryCollectionStateStub()
.withSelection(userSelection);
const collectionStateStub = new UseCollectionStateStub()
.withState(initialCollectionState);
const { returnObject } = runHook({ useCollectionState: collectionStateStub });
let isChangeTriggered = false;
watch(returnObject.currentSelection, () => {
isChangeTriggered = true;
});
// act
collectionStateStub.triggerOnStateChange({
newState: changedCollectionState,
immediateOnly: false,
});
await nextTick();
// assert
expect(isChangeTriggered).to.equal(true);
});
});
});
describe('once selection state is changed', () => {
it('updated with same collection state', async () => {
// arrange
const initialScripts = [new ScriptStub('initialSelectedScript')];
const changedScripts = [new SelectedScriptStub('changedSelectedScript')];
const selectionState = new UserSelectionStub(initialScripts);
const collectionState = new CategoryCollectionStateStub().withSelection(selectionState);
const collectionStateStub = new UseCollectionStateStub().withState(collectionState);
const { returnObject } = runHook({
useCollectionState: collectionStateStub,
});
// act
selectionState.triggerSelectionChangedEvent(changedScripts);
await nextTick();
// assert
const actualSelection = returnObject.currentSelection.value;
expect(actualSelection).to.equal(selectionState);
});
it('updated once collection state is changed', async () => {
// arrange
const changedScripts = [new SelectedScriptStub('changedSelectedScript')];
const newSelectionState = new UserSelectionStub([new ScriptStub('initialSelectedScriptInNewCollection')]);
const initialCollectionState = new CategoryCollectionStateStub().withSelectedScripts([new SelectedScriptStub('initialSelectedScriptInInitialCollection')]);
const collectionStateStub = new UseCollectionStateStub().withState(initialCollectionState);
const { returnObject } = runHook({ useCollectionState: collectionStateStub });
// act
collectionStateStub.triggerOnStateChange({
newState: new CategoryCollectionStateStub().withSelection(newSelectionState),
immediateOnly: false,
});
newSelectionState.triggerSelectionChangedEvent(changedScripts);
// assert
const actualSelection = returnObject.currentSelection.value;
expect(actualSelection).to.equal(newSelectionState);
});
describe('triggers change', () => {
it('with new selected scripts array reference', async () => {
// arrange
const oldSelectedScriptsArrayReference = [];
const newSelectedScriptsArrayReference = [];
const userSelection = new UserSelectionStub(oldSelectedScriptsArrayReference)
.withSelectedScripts(oldSelectedScriptsArrayReference);
const collectionStateStub = new UseCollectionStateStub()
.withState(new CategoryCollectionStateStub().withSelection(userSelection));
const { returnObject } = runHook({ useCollectionState: collectionStateStub });
let isChangeTriggered = false;
watch(returnObject.currentSelection, () => {
isChangeTriggered = true;
});
// act
userSelection.triggerSelectionChangedEvent(newSelectedScriptsArrayReference);
await nextTick();
// assert
expect(isChangeTriggered).to.equal(true);
});
it('with same selected scripts array reference', async () => {
// arrange
const sharedSelectedScriptsReference = [];
const userSelection = new UserSelectionStub([])
.withSelectedScripts(sharedSelectedScriptsReference);
const collectionStateStub = new UseCollectionStateStub()
.withState(new CategoryCollectionStateStub().withSelection(userSelection));
const { returnObject } = runHook({ useCollectionState: collectionStateStub });
let isChangeTriggered = false;
watch(returnObject.currentSelection, () => {
isChangeTriggered = true;
});
// act
userSelection.triggerSelectionChangedEvent(sharedSelectedScriptsReference);
await nextTick();
// assert
expect(isChangeTriggered).to.equal(true);
});
});
});
});
describe('modifyCurrentSelection', () => {
it('should modify current state', () => {
// arrange
const { returnObject, collectionStateStub } = runHook();
const expectedSelection = collectionStateStub.state.selection;
let mutatedSelection: IUserSelection | undefined;
const mutator: SelectionModifier = (selection) => {
mutatedSelection = selection;
};
// act
returnObject.modifyCurrentSelection(mutator);
// assert
expect(collectionStateStub.isStateModified()).to.equal(true);
expect(mutatedSelection).to.equal(expectedSelection);
});
it('new state is modified once collection state is changed', async () => {
// arrange
const { returnObject, collectionStateStub } = runHook();
const expectedSelection = new UserSelectionStub([]);
const newCollectionState = new CategoryCollectionStateStub()
.withSelection(expectedSelection);
let mutatedSelection: IUserSelection | undefined;
const mutator: SelectionModifier = (selection) => {
mutatedSelection = selection;
};
// act
collectionStateStub.triggerOnStateChange({
newState: newCollectionState,
immediateOnly: false,
});
await nextTick();
returnObject.modifyCurrentSelection(mutator);
// assert
expect(collectionStateStub.isStateModified()).to.equal(true);
expect(mutatedSelection).to.equal(expectedSelection);
});
it('old state is not modified once collection state is changed', async () => {
// arrange
const oldState = new CategoryCollectionStateStub().withSelectedScripts([
new SelectedScriptStub('scriptFromOldState'),
]);
const collectionStateStub = new UseCollectionStateStub()
.withState(oldState);
const { returnObject } = runHook({ useCollectionState: collectionStateStub });
const expectedSelection = new UserSelectionStub([]);
const newCollectionState = new CategoryCollectionStateStub()
.withSelection(expectedSelection);
let totalMutations = 0;
const mutator: SelectionModifier = () => {
totalMutations++;
};
// act
collectionStateStub.triggerOnStateChange({
newState: newCollectionState,
immediateOnly: false,
});
await nextTick();
returnObject.modifyCurrentSelection(mutator);
// assert
expect(totalMutations).to.equal(1);
});
});
});
function runHook(scenario?: {
useCollectionState?: UseCollectionStateStub,
}) {
const collectionStateStub = scenario?.useCollectionState ?? new UseCollectionStateStub();
const eventsStub = new UseAutoUnsubscribedEventsStub();
const returnObject = useUserSelectionState(
collectionStateStub.get(),
eventsStub.get(),
);
return {
returnObject,
collectionStateStub,
eventsStub,
};
}

View File

@@ -45,6 +45,7 @@ export class EventSubscriptionCollectionStub
args: [subscriptions],
});
// Not calling other methods to avoid registering method calls.
this.subscriptions.forEach((subscription) => subscription.unsubscribe());
this.subscriptions.splice(0, this.subscriptions.length, ...subscriptions);
}
}

View File

@@ -50,21 +50,46 @@ export class UseCollectionStateStub
return this.currentState.value;
}
public isStateModified(): boolean {
const call = this.callHistory.find((c) => c.methodName === 'modifyCurrentState');
return call !== undefined;
}
public triggerImmediateStateChange(): void {
this.triggerOnStateChange({
newState: this.currentState.value,
immediateOnly: true,
});
}
public triggerOnStateChange(scenario: {
readonly newState: ICategoryCollectionState,
readonly immediateOnly: boolean,
}): void {
this.currentState.value = scenario.newState;
let calls = this.callHistory.filter((call) => call.methodName === 'onStateChange');
let handlers = this.getRegisteredHandlers();
if (scenario.immediateOnly) {
calls = calls.filter((call) => call.args[1].immediate === true);
handlers = handlers.filter((args) => args[1]?.immediate === true);
}
const handlers = calls.map((call) => call.args[0] as NewStateEventHandler);
handlers.forEach(
const callbacks = handlers.map((args) => args[0] as NewStateEventHandler);
if (!callbacks.length) {
throw new Error('No handler callbacks are registered to handle state change');
}
callbacks.forEach(
(handler) => handler(scenario.newState, undefined),
);
}
public get(): ReturnType<typeof useCollectionState> {
return {
modifyCurrentState: this.modifyCurrentState.bind(this),
modifyCurrentContext: this.modifyCurrentContext.bind(this),
onStateChange: this.onStateChange.bind(this),
currentContext: this.currentContext,
currentState: this.currentState,
};
}
private onStateChange(
handler: NewStateEventHandler,
settings?: Partial<IStateCallbackSettings>,
@@ -94,13 +119,14 @@ export class UseCollectionStateStub
});
}
public get(): ReturnType<typeof useCollectionState> {
return {
modifyCurrentState: this.modifyCurrentState.bind(this),
modifyCurrentContext: this.modifyCurrentContext.bind(this),
onStateChange: this.onStateChange.bind(this),
currentContext: this.currentContext,
currentState: this.currentState,
};
private getRegisteredHandlers(): readonly Parameters<ReturnType<typeof useCollectionState>['onStateChange']>[] {
const calls = this.callHistory.filter((call) => call.methodName === 'onStateChange');
return calls.map((handler) => {
const [callback, settings] = handler.args;
return [
callback as NewStateEventHandler,
settings as Partial<IStateCallbackSettings>,
];
});
}
}

View File

@@ -0,0 +1,49 @@
import { shallowRef } from 'vue';
import type { SelectionModifier, useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
import { UserSelectionStub } from './UserSelectionStub';
export class UseUserSelectionStateStub
extends StubWithObservableMethodCalls<ReturnType<typeof useUserSelectionState>> {
private readonly currentSelection = shallowRef<IUserSelection>(
new UserSelectionStub([]),
);
private modifyCurrentSelection(mutator: SelectionModifier) {
mutator(this.currentSelection.value);
this.registerMethodCall({
methodName: 'modifyCurrentSelection',
args: [mutator],
});
}
public withUserSelection(userSelection: IUserSelection): this {
this.currentSelection.value = userSelection;
return this;
}
public withSelectedScripts(selectedScripts: readonly SelectedScript[]): this {
return this.withUserSelection(
new UserSelectionStub(selectedScripts.map((s) => s.script))
.withSelectedScripts(selectedScripts),
);
}
public get selection(): IUserSelection {
return this.currentSelection.value;
}
public isSelectionModified(): boolean {
const modifyCall = this.callHistory.find((call) => call.methodName === 'modifyCurrentSelection');
return modifyCall !== undefined;
}
public get(): ReturnType<typeof useUserSelectionState> {
return {
currentSelection: this.currentSelection,
modifyCurrentSelection: this.modifyCurrentSelection.bind(this),
};
}
}

View File

@@ -25,6 +25,20 @@ export class UserSelectionStub
return this;
}
public isScriptAdded(scriptId: string): boolean {
const call = this.callHistory.find(
(c) => c.methodName === 'addSelectedScript' && c.args[0] === scriptId,
);
return call !== undefined;
}
public isScriptRemoved(scriptId: string): boolean {
const call = this.callHistory.find(
(c) => c.methodName === 'removeSelectedScript' && c.args[0] === scriptId,
);
return call !== undefined;
}
public areAllSelected(): boolean {
throw new Error('Method not implemented.');
}