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

@@ -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,
};
}