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

@@ -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));
useSelectionStateStub.withUserSelection(selectionStub);
const node = createTreeNodeStub({
isBranch: false,
currentState: TreeNodeCheckState.Checked,
});
const mockEvent = new TreeNodeStateChangedEmittedEventStub()
.withNode(
createTreeNodeStub({
isBranch: false,
currentState: TreeNodeCheckState.Checked,
}),
)
.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));
useSelectionStateStub.withUserSelection(selectionStub);
const node = createTreeNodeStub({
isBranch: false,
currentState: TreeNodeCheckState.Unchecked,
});
const mockEvent = new TreeNodeStateChangedEmittedEventStub()
.withNode(
createTreeNodeStub({
isBranch: false,
currentState: TreeNodeCheckState.Unchecked,
}),
)
.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,
};
}