Fix code highlighting and optimize category select

This commit introduces a batched debounce mechanism for managing user
selection state changes. It effectively reduces unnecessary processing
during rapid script checking, preventing multiple triggers for code
compilation and UI rendering.

Key improvements include:

- Enhanced performance, especially noticeable when selecting large
  categories. This update resolves minor UI freezes experienced when
  selecting categories with numerous scripts.
- Correction of a bug where the code area only highlighted the last
  selected script when multiple scripts were chosen.

Other changes include:

- Timing functions:
  - Create a `Timing` folder for `throttle` and the new
    `batchedDebounce` functions.
  - Move these functions to the application layer from the presentation
    layer, reflecting their application-wide use.
  - Refactor existing code for improved clarity, naming consistency, and
    adherence to new naming conventions.
  - Add missing unit tests.
- `UserSelection`:
  - State modifications in `UserSelection` now utilize a singular object
    inspired by the CQRS pattern, enabling batch updates and flexible
    change configurations, thereby simplifying change management.
- Remove the `I` prefix from related interfaces to align with new coding
  standards.
- Refactor related code for better testability in isolation with
  dependency injection.
- Repository:
  - Move repository abstractions to the application layer.
  - Improve repository abstraction to combine `ReadonlyRepository` and
    `MutableRepository` interfaces.
- E2E testing:
  - Introduce E2E tests to validate the correct batch selection
    behavior.
  - Add a specialized data attribute in `TheCodeArea.vue` for improved
    testability.
  - Reorganize shared Cypress functions for a more idiomatic Cypress
    approach.
  - Improve test documentation with related information.
- `SelectedScript`:
  - Create an abstraction for simplified testability.
  - Introduce `SelectedScriptStub` in tests as a substitute for the
    actual object.
This commit is contained in:
undergroundwires
2023-11-18 22:23:27 +01:00
parent 4531645b4c
commit cb42f11b97
79 changed files with 2733 additions and 1351 deletions

View File

@@ -0,0 +1,272 @@
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { ScriptSelection } from '@/application/Context/State/Selection/Script/ScriptSelection';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { ScriptToCategorySelectionMapper } from '@/application/Context/State/Selection/Category/ScriptToCategorySelectionMapper';
import { ScriptSelectionStub } from '@tests/unit/shared/Stubs/ScriptSelectionStub';
import { CategorySelectionChange } from '@/application/Context/State/Selection/Category/CategorySelectionChange';
import { ScriptSelectionChange, ScriptSelectionChangeCommand } from '@/application/Context/State/Selection/Script/ScriptSelectionChange';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { ICategory, IScript } from '@/domain/ICategory';
describe('ScriptToCategorySelectionMapper', () => {
describe('areAllScriptsSelected', () => {
it('should return false for partially selected scripts', () => {
// arrange
const expected = false;
const { sut, category } = setupTestWithPreselectedScripts({
preselect: (allScripts) => [allScripts[0]],
});
// act
const actual = sut.areAllScriptsSelected(category);
// assert
expect(actual).to.equal(expected);
});
it('should return true when all scripts are selected', () => {
// arrange
const expected = true;
const { sut, category } = setupTestWithPreselectedScripts({
preselect: (allScripts) => [...allScripts],
});
// act
const actual = sut.areAllScriptsSelected(category);
// assert
expect(actual).to.equal(expected);
});
});
describe('isAnyScriptSelected', () => {
it('should return false with no selected scripts', () => {
// arrange
const expected = false;
const { sut, category } = setupTestWithPreselectedScripts({
preselect: () => [],
});
// act
const actual = sut.isAnyScriptSelected(category);
// assert
expect(actual).to.equal(expected);
});
it('should return true with at least one script selected', () => {
// arrange
const expected = true;
const { sut, category } = setupTestWithPreselectedScripts({
preselect: (allScripts) => [allScripts[0]],
});
// act
const actual = sut.isAnyScriptSelected(category);
// assert
expect(actual).to.equal(expected);
});
});
describe('processChanges', () => {
const testScenarios: ReadonlyArray<{
readonly description: string;
readonly changes: readonly CategorySelectionChange[];
readonly categories: ReadonlyArray<{
readonly categoryId: ICategory['id'],
readonly scriptIds: readonly IScript['id'][],
}>;
readonly expected: readonly ScriptSelectionChange[],
}> = [
{
description: 'single script: select without revert',
categories: [
{ categoryId: 1, scriptIds: ['single-script'] },
],
changes: [
{ categoryId: 1, newStatus: { isSelected: true, isReverted: false } },
],
expected: [
{ scriptId: 'single-script', newStatus: { isSelected: true, isReverted: false } },
],
},
{
description: 'multiple scripts: select without revert',
categories: [
{ categoryId: 1, scriptIds: ['script1-cat1', 'script2-cat1'] },
{ categoryId: 2, scriptIds: ['script3-cat2'] },
],
changes: [
{ categoryId: 1, newStatus: { isSelected: true, isReverted: false } },
{ categoryId: 2, newStatus: { isSelected: true, isReverted: false } },
],
expected: [
{ scriptId: 'script1-cat1', newStatus: { isSelected: true, isReverted: false } },
{ scriptId: 'script2-cat1', newStatus: { isSelected: true, isReverted: false } },
{ scriptId: 'script3-cat2', newStatus: { isSelected: true, isReverted: false } },
],
},
{
description: 'single script: select with revert',
categories: [
{ categoryId: 1, scriptIds: ['single-script'] },
],
changes: [
{ categoryId: 1, newStatus: { isSelected: true, isReverted: true } },
],
expected: [
{ scriptId: 'single-script', newStatus: { isSelected: true, isReverted: true } },
],
},
{
description: 'multiple scripts: select with revert',
categories: [
{ categoryId: 1, scriptIds: ['script-1-cat-1'] },
{ categoryId: 2, scriptIds: ['script-2-cat-2'] },
{ categoryId: 3, scriptIds: ['script-3-cat-3'] },
],
changes: [
{ categoryId: 1, newStatus: { isSelected: true, isReverted: true } },
{ categoryId: 2, newStatus: { isSelected: true, isReverted: true } },
{ categoryId: 3, newStatus: { isSelected: true, isReverted: true } },
],
expected: [
{ scriptId: 'script-1-cat-1', newStatus: { isSelected: true, isReverted: true } },
{ scriptId: 'script-2-cat-2', newStatus: { isSelected: true, isReverted: true } },
{ scriptId: 'script-3-cat-3', newStatus: { isSelected: true, isReverted: true } },
],
},
{
description: 'single script: deselect',
categories: [
{ categoryId: 1, scriptIds: ['single-script'] },
],
changes: [
{ categoryId: 1, newStatus: { isSelected: false } },
],
expected: [
{ scriptId: 'single-script', newStatus: { isSelected: false } },
],
},
{
description: 'multiple scripts: deselect',
categories: [
{ categoryId: 1, scriptIds: ['script-1-cat1'] },
{ categoryId: 2, scriptIds: ['script-2-cat2'] },
],
changes: [
{ categoryId: 1, newStatus: { isSelected: false } },
{ categoryId: 2, newStatus: { isSelected: false } },
],
expected: [
{ scriptId: 'script-1-cat1', newStatus: { isSelected: false } },
{ scriptId: 'script-2-cat2', newStatus: { isSelected: false } },
],
},
{
description: 'mixed operations (select, revert, deselect)',
categories: [
{ categoryId: 1, scriptIds: ['to-revert'] },
{ categoryId: 2, scriptIds: ['not-revert'] },
{ categoryId: 3, scriptIds: ['to-deselect'] },
],
changes: [
{ categoryId: 1, newStatus: { isSelected: true, isReverted: true } },
{ categoryId: 2, newStatus: { isSelected: true, isReverted: false } },
{ categoryId: 3, newStatus: { isSelected: false } },
],
expected: [
{ scriptId: 'to-revert', newStatus: { isSelected: true, isReverted: true } },
{ scriptId: 'not-revert', newStatus: { isSelected: true, isReverted: false } },
{ scriptId: 'to-deselect', newStatus: { isSelected: false } },
],
},
{
description: 'affecting selected categories only',
categories: [
{ categoryId: 1, scriptIds: ['relevant-1', 'relevant-2'] },
{ categoryId: 2, scriptIds: ['not-relevant-1', 'not-relevant-2'] },
{ categoryId: 3, scriptIds: ['not-relevant-3', 'not-relevant-4'] },
],
changes: [
{ categoryId: 1, newStatus: { isSelected: true, isReverted: true } },
],
expected: [
{ scriptId: 'relevant-1', newStatus: { isSelected: true, isReverted: true } },
{ scriptId: 'relevant-2', newStatus: { isSelected: true, isReverted: true } },
],
},
];
testScenarios.forEach(({
description, changes, categories, expected,
}) => {
it(description, () => {
// arrange
const scriptSelectionStub = new ScriptSelectionStub();
const sut = new ScriptToCategorySelectionMapperBuilder()
.withScriptSelection(scriptSelectionStub)
.withCollection(new CategoryCollectionStub().withAction(
new CategoryStub(99)
// Register scripts to test for nested items
.withAllScriptIdsRecursively(...categories.flatMap((c) => c.scriptIds))
.withCategories(...categories.map(
(c) => new CategoryStub(c.categoryId).withAllScriptIdsRecursively(...c.scriptIds),
)),
))
.build();
// act
sut.processChanges({
changes,
});
// assert
expect(scriptSelectionStub.callHistory).to.have.lengthOf(1);
const call = scriptSelectionStub.callHistory.find((m) => m.methodName === 'processChanges');
expectExists(call);
const [command] = call.args;
const { changes: actualChanges } = (command as ScriptSelectionChangeCommand);
expect(actualChanges).to.have.lengthOf(expected.length);
expect(actualChanges).to.deep.members(expected);
});
});
});
});
class ScriptToCategorySelectionMapperBuilder {
private scriptSelection: ScriptSelection = new ScriptSelectionStub();
private collection: ICategoryCollection = new CategoryCollectionStub();
public withScriptSelection(scriptSelection: ScriptSelection): this {
this.scriptSelection = scriptSelection;
return this;
}
public withCollection(collection: ICategoryCollection): this {
this.collection = collection;
return this;
}
public build(): ScriptToCategorySelectionMapper {
return new ScriptToCategorySelectionMapper(
this.scriptSelection,
this.collection,
);
}
}
type TestScripts = readonly [ScriptStub, ScriptStub, ScriptStub];
function setupTestWithPreselectedScripts(options: {
preselect: (allScripts: TestScripts) => readonly ScriptStub[],
}) {
const allScripts: TestScripts = [
new ScriptStub('first-script'),
new ScriptStub('second-script'),
new ScriptStub('third-script'),
];
const preselectedScripts = options.preselect(allScripts);
const category = new CategoryStub(1)
.withAllScriptsRecursively(...allScripts); // Register scripts to test for nested items
const collection = new CategoryCollectionStub().withAction(category);
const sut = new ScriptToCategorySelectionMapperBuilder()
.withCollection(collection)
.withScriptSelection(
new ScriptSelectionStub()
.withSelectedScripts(preselectedScripts.map((s) => s.toSelectedScript())),
)
.build();
return {
category,
sut,
};
}

View File

@@ -0,0 +1,628 @@
import { describe, it, expect } from 'vitest';
import { DebounceFunction, DebouncedScriptSelection } from '@/application/Context/State/Selection/Script/DebouncedScriptSelection';
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { BatchedDebounceStub } from '@tests/unit/shared/Stubs/BatchedDebounceStub';
import { ScriptSelectionChange, ScriptSelectionChangeCommand } from '@/application/Context/State/Selection/Script/ScriptSelectionChange';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { IScript } from '@/domain/IScript';
import { expectEqualSelectedScripts } from './ExpectEqualSelectedScripts';
type DebounceArg = ScriptSelectionChangeCommand;
describe('DebouncedScriptSelection', () => {
describe('constructor', () => {
describe('initialization of selected scripts', () => {
const testScenarios: ReadonlyArray<{
readonly description: string;
readonly selectedScripts: readonly SelectedScript[];
}> = [
{
description: 'initializes with no scripts when given empty array',
selectedScripts: [],
},
{
description: 'initializes with a single script when given one script',
selectedScripts: [new SelectedScriptStub(new ScriptStub('s1'))],
},
{
description: 'initializes with multiple scripts when given multiple scripts',
selectedScripts: [
new SelectedScriptStub(new ScriptStub('s1')),
new SelectedScriptStub(new ScriptStub('s2')),
],
},
];
testScenarios.forEach(({ description, selectedScripts }) => {
it(description, () => {
// arrange
const expectedScripts = selectedScripts;
const builder = new DebouncedScriptSelectionBuilder()
.withSelectedScripts(selectedScripts);
// act
const selection = builder.build();
const actualScripts = selection.selectedScripts;
// assert
expectEqualSelectedScripts(actualScripts, expectedScripts);
});
});
});
describe('debounce configuration', () => {
/*
Note: These tests cover internal implementation details, particularly the debouncing logic,
to ensure comprehensive code coverage. They are not focused on the public API. While useful
for detecting subtle bugs, they might need updates during refactoring if internal structures
change but external behaviors remain the same.
*/
it('sets up debounce with a callback function', () => {
// arrange
const debounceStub = new BatchedDebounceStub<DebounceArg>();
const builder = new DebouncedScriptSelectionBuilder()
.withBatchedDebounce(debounceStub.func);
// act
builder.build();
// assert
expect(debounceStub.callHistory).to.have.lengthOf(1);
const [debounceFunc] = debounceStub.callHistory[0];
expectExists(debounceFunc);
});
it('configures debounce with specific delay ', () => {
// arrange
const expectedDebounceInMs = 100;
const debounceStub = new BatchedDebounceStub<DebounceArg>();
const builder = new DebouncedScriptSelectionBuilder()
.withBatchedDebounce(debounceStub.func);
// act
builder.build();
// assert
expect(debounceStub.callHistory).to.have.lengthOf(1);
const [, waitInMs] = debounceStub.callHistory[0];
expect(waitInMs).to.equal(expectedDebounceInMs);
});
it('applies debouncing to processChanges method', () => {
// arrange
const expectedFunc = () => {};
const debounceMock: DebounceFunction = () => expectedFunc;
const builder = new DebouncedScriptSelectionBuilder()
.withBatchedDebounce(debounceMock);
// act
const selection = builder.build();
// assert
const actualFunction = selection.processChanges;
expect(actualFunction).to.equal(expectedFunc);
});
});
});
describe('isSelected', () => {
it('returns false for an unselected script', () => {
// arrange
const expectedResult = false;
const { scriptSelection, unselectedScripts } = setupTestWithPreselectedScripts({
preselect: (allScripts) => [allScripts[0]],
});
const scriptIdToCheck = unselectedScripts[0].id;
// act
const actual = scriptSelection.isSelected(scriptIdToCheck);
// assert
expect(actual).to.equal(expectedResult);
});
it('returns true for a selected script', () => {
// arrange
const expectedResult = true;
const { scriptSelection, preselectedScripts } = setupTestWithPreselectedScripts({
preselect: (allScripts) => [allScripts[0]],
});
const scriptIdToCheck = preselectedScripts[0].id;
// act
const actual = scriptSelection.isSelected(scriptIdToCheck);
// assert
expect(actual).to.equal(expectedResult);
});
});
describe('deselectAll', () => {
it('removes all selected scripts', () => {
// arrange
const { scriptSelection, changeEvents } = setupTestWithPreselectedScripts({
preselect: (scripts) => [scripts[0], scripts[1]],
});
// act
scriptSelection.deselectAll();
// assert
expect(changeEvents).to.have.lengthOf(1);
expect(changeEvents[0]).to.have.lengthOf(0);
expect(scriptSelection.selectedScripts).to.have.lengthOf(0);
});
it('does not notify when no scripts are selected', () => {
// arrange
const { scriptSelection, changeEvents } = setupTestWithPreselectedScripts({
preselect: () => [],
});
// act
scriptSelection.deselectAll();
// assert
expect(changeEvents).to.have.lengthOf(0);
expect(scriptSelection.selectedScripts).to.have.lengthOf(0);
});
});
describe('selectAll', () => {
it('selects all available scripts', () => {
// arrange
const selectedRevertState = false;
const { scriptSelection, changeEvents, allScripts } = setupTestWithPreselectedScripts({
preselect: () => [],
});
const expectedSelection = allScripts.map(
(s) => s.toSelectedScript().withRevert(selectedRevertState),
);
// act
scriptSelection.selectAll();
// assert
expect(changeEvents).to.have.lengthOf(1);
expectEqualSelectedScripts(changeEvents[0], expectedSelection);
expectEqualSelectedScripts(scriptSelection.selectedScripts, expectedSelection);
});
it('does not notify when no new scripts are selected', () => {
// arrange
const { scriptSelection, changeEvents } = setupTestWithPreselectedScripts({
preselect: (allScripts) => allScripts,
});
// act
scriptSelection.selectAll();
// assert
expect(changeEvents).to.have.lengthOf(0);
});
});
describe('selectOnly', () => {
describe('selects correctly', () => {
const testScenarios: ReadonlyArray<{
readonly description: string;
readonly preselect: (allScripts: TestScripts) => readonly SelectedScriptStub[],
readonly toSelect: (allScripts: TestScripts) => readonly ScriptStub[];
readonly getExpectedFinalSelection: (allScripts: TestScripts) => readonly SelectedScript[],
}> = [
{
description: 'adds expected scripts to empty selection as non-reverted',
preselect: () => [],
toSelect: (allScripts) => [allScripts[0]],
getExpectedFinalSelection: (allScripts) => [
allScripts[0].toSelectedScript().withRevert(false),
],
},
{
description: 'adds expected scripts to existing selection as non-reverted',
preselect: (allScripts) => [allScripts[0], allScripts[1]]
.map((s) => s.toSelectedScript().withRevert(false)),
toSelect: (allScripts) => [...allScripts],
getExpectedFinalSelection: (allScripts) => allScripts
.map((s) => s.toSelectedScript().withRevert(false)),
},
{
description: 'removes other scripts from selection',
preselect: (allScripts) => allScripts
.map((s) => s.toSelectedScript().withRevert(false)),
toSelect: (allScripts) => [allScripts[0]],
getExpectedFinalSelection: (allScripts) => [
allScripts[0].toSelectedScript().withRevert(false),
],
},
{
description: 'handles both addition and removal of scripts correctly',
preselect: (allScripts) => [allScripts[0], allScripts[2]] // Removes "2"
.map((s) => s.toSelectedScript().withRevert(false)),
toSelect: (allScripts) => [allScripts[0], allScripts[1]], // Adds "1"
getExpectedFinalSelection: (allScripts) => [
allScripts[0].toSelectedScript().withRevert(false),
allScripts[1].toSelectedScript().withRevert(false),
],
},
];
testScenarios.forEach(({
description, preselect, toSelect, getExpectedFinalSelection,
}) => {
it(description, () => {
// arrange
const { scriptSelection, changeEvents, allScripts } = setupTestWithPreselectedScripts({
preselect,
});
const scriptsToSelect = toSelect(allScripts);
const expectedSelection = getExpectedFinalSelection(allScripts);
// act
scriptSelection.selectOnly(scriptsToSelect);
// assert
expect(changeEvents).to.have.lengthOf(1);
expectEqualSelectedScripts(changeEvents[0], expectedSelection);
expectEqualSelectedScripts(scriptSelection.selectedScripts, expectedSelection);
});
});
});
describe('does not notify for unchanged selection', () => {
const testScenarios: ReadonlyArray<{
readonly description: string;
readonly preselect: TestScriptSelector;
}> = [
{
description: 'unchanged selection with reverted scripts',
preselect: (allScripts) => allScripts.map((s) => s.toSelectedScript().withRevert(true)),
},
{
description: 'unchanged selection with non-reverted scripts',
preselect: (allScripts) => allScripts.map((s) => s.toSelectedScript().withRevert(false)),
},
{
description: 'unchanged selection with mixed revert states',
preselect: (allScripts) => [
allScripts[0].toSelectedScript().withRevert(true),
allScripts[1].toSelectedScript().withRevert(false),
],
},
];
testScenarios.forEach(({
description, preselect,
}) => {
it(description, () => {
// arrange
const {
scriptSelection, changeEvents, preselectedScripts,
} = setupTestWithPreselectedScripts({ preselect });
const scriptsToSelect = preselectedScripts.map((s) => s.script);
// act
scriptSelection.selectOnly(scriptsToSelect);
// assert
expect(changeEvents).to.have.lengthOf(0);
});
});
});
it('throws error when an empty script array is passed', () => {
// arrange
const expectedError = 'Provided script array is empty. To deselect all scripts, please use the deselectAll() method instead.';
const scripts = [];
const scriptSelection = new DebouncedScriptSelectionBuilder().build();
// act
const act = () => scriptSelection.selectOnly(scripts);
// assert
expect(act).to.throw(expectedError);
});
});
describe('processChanges', () => {
describe('mutates correctly', () => {
const testScenarios: ReadonlyArray<{
readonly description: string;
readonly preselect: TestScriptSelector;
readonly getChanges: (allScripts: TestScripts) => readonly ScriptSelectionChange[];
readonly getExpectedFinalSelection: (allScripts: TestScripts) => readonly SelectedScript[],
}> = [
{
description: 'correctly adds a new reverted script',
preselect: (allScripts) => [allScripts[0], allScripts[1]]
.map((s) => s.toSelectedScript()),
getChanges: (allScripts) => [
{ scriptId: allScripts[2].id, newStatus: { isReverted: true, isSelected: true } },
],
getExpectedFinalSelection: (allScripts) => [
allScripts[0].toSelectedScript(),
allScripts[1].toSelectedScript(),
new SelectedScriptStub(allScripts[2]).withRevert(true),
],
},
{
description: 'correctly adds a new non-reverted script',
preselect: (allScripts) => [allScripts[0], allScripts[1]]
.map((s) => s.toSelectedScript()),
getChanges: (allScripts) => [
{ scriptId: allScripts[2].id, newStatus: { isReverted: false, isSelected: true } },
],
getExpectedFinalSelection: (allScripts) => [
allScripts[0].toSelectedScript(),
allScripts[1].toSelectedScript(),
new SelectedScriptStub(allScripts[2]).withRevert(false),
],
},
{
description: 'correctly removes an existing script',
preselect: (allScripts) => [allScripts[0], allScripts[1]]
.map((s) => s.toSelectedScript()),
getChanges: (allScripts) => [
{ scriptId: allScripts[0].id, newStatus: { isSelected: false } },
],
getExpectedFinalSelection: (allScripts) => [
allScripts[1].toSelectedScript(),
],
},
{
description: 'updates revert status to true for an existing script',
preselect: (allScripts) => [
allScripts[0].toSelectedScript().withRevert(false),
allScripts[1].toSelectedScript(),
],
getChanges: (allScripts) => [
{ scriptId: allScripts[0].id, newStatus: { isSelected: true, isReverted: true } },
],
getExpectedFinalSelection: (allScripts) => [
allScripts[0].toSelectedScript().withRevert(true),
allScripts[1].toSelectedScript(),
],
},
{
description: 'updates revert status to false for an existing script',
preselect: (allScripts) => [
allScripts[0].toSelectedScript().withRevert(true),
allScripts[1].toSelectedScript(),
],
getChanges: (allScripts) => [
{ scriptId: allScripts[0].id, newStatus: { isSelected: true, isReverted: false } },
],
getExpectedFinalSelection: (allScripts) => [
allScripts[0].toSelectedScript().withRevert(false),
allScripts[1].toSelectedScript(),
],
},
{
description: 'handles mixed operations: add, update, remove',
preselect: (allScripts) => [
allScripts[0].toSelectedScript().withRevert(true), // update
allScripts[2].toSelectedScript(), // remove
],
getChanges: (allScripts) => [
{ scriptId: allScripts[0].id, newStatus: { isSelected: true, isReverted: false } },
{ scriptId: allScripts[1].id, newStatus: { isSelected: true, isReverted: true } },
{ scriptId: allScripts[2].id, newStatus: { isSelected: false } },
],
getExpectedFinalSelection: (allScripts) => [
allScripts[0].toSelectedScript().withRevert(false),
allScripts[1].toSelectedScript().withRevert(true),
],
},
];
testScenarios.forEach(({
description, preselect, getChanges, getExpectedFinalSelection,
}) => {
it(description, () => {
// arrange
const { scriptSelection, changeEvents, allScripts } = setupTestWithPreselectedScripts({
preselect,
});
const changes = getChanges(allScripts);
// act
scriptSelection.processChanges({
changes,
});
// assert
const expectedSelection = getExpectedFinalSelection(allScripts);
expect(changeEvents).to.have.lengthOf(1);
expectEqualSelectedScripts(changeEvents[0], expectedSelection);
expectEqualSelectedScripts(scriptSelection.selectedScripts, expectedSelection);
});
});
});
describe('does not mutate for unchanged data', () => {
const testScenarios: ReadonlyArray<{
readonly description: string;
readonly preselect: TestScriptSelector;
readonly getChanges: (allScripts: TestScripts) => readonly ScriptSelectionChange[];
}> = [
{
description: 'does not change selection for an already selected script',
preselect: (allScripts) => [allScripts[0].toSelectedScript().withRevert(true)],
getChanges: (allScripts) => [
{ scriptId: allScripts[0].id, newStatus: { isReverted: true, isSelected: true } },
],
},
{
description: 'does not change selection when deselecting a missing script',
preselect: (allScripts) => [allScripts[0], allScripts[1]]
.map((s) => s.toSelectedScript()),
getChanges: (allScripts) => [
{ scriptId: allScripts[2].id, newStatus: { isSelected: false } },
],
},
{
description: 'handles no mutations for mixed unchanged operations',
preselect: (allScripts) => [allScripts[0].toSelectedScript().withRevert(false)],
getChanges: (allScripts) => [
{ scriptId: allScripts[0].id, newStatus: { isSelected: true, isReverted: false } },
{ scriptId: allScripts[1].id, newStatus: { isSelected: false } },
],
},
];
testScenarios.forEach(({
description, preselect, getChanges,
}) => {
it(description, () => {
// arrange
const { scriptSelection, changeEvents, allScripts } = setupTestWithPreselectedScripts({
preselect,
});
const initialSelection = [...scriptSelection.selectedScripts];
const changes = getChanges(allScripts);
// act
scriptSelection.processChanges({
changes,
});
// assert
expect(changeEvents).to.have.lengthOf(0);
expectEqualSelectedScripts(scriptSelection.selectedScripts, initialSelection);
});
});
});
describe('debouncing', () => {
it('queues commands for debouncing', () => {
// arrange
const debounceStub = new BatchedDebounceStub<DebounceArg>();
const script = new ScriptStub('test');
const selection = new DebouncedScriptSelectionBuilder()
.withBatchedDebounce(debounceStub.func)
.withCollection(createCollectionWithScripts(script))
.build();
const expectedCommand: ScriptSelectionChangeCommand = {
changes: [
{ scriptId: script.id, newStatus: { isReverted: true, isSelected: true } },
],
};
// act
selection.processChanges(expectedCommand);
// assert
expect(debounceStub.collectedArgs).to.have.lengthOf(1);
expect(debounceStub.collectedArgs[0]).to.equal(expectedCommand);
});
it('does not apply changes during debouncing period', () => {
// arrange
const debounceStub = new BatchedDebounceStub<DebounceArg>()
.withImmediateDebouncing(false);
const script = new ScriptStub('test');
const selection = new DebouncedScriptSelectionBuilder()
.withBatchedDebounce(debounceStub.func)
.withCollection(createCollectionWithScripts(script))
.build();
const changeEvents = watchForChangeEvents(selection);
// act
selection.processChanges({
changes: [
{ scriptId: script.id, newStatus: { isReverted: true, isSelected: true } },
],
});
// assert
expect(changeEvents).to.have.lengthOf(0);
expectEqualSelectedScripts(selection.selectedScripts, []);
});
it('applies single change after debouncing period', () => {
// arrange
const debounceStub = new BatchedDebounceStub<DebounceArg>()
.withImmediateDebouncing(false);
const script = new ScriptStub('test');
const selection = new DebouncedScriptSelectionBuilder()
.withBatchedDebounce(debounceStub.func)
.withCollection(createCollectionWithScripts(script))
.build();
const changeEvents = watchForChangeEvents(selection);
const expectedSelection = [script.toSelectedScript().withRevert(true)];
// act
selection.processChanges({
changes: [
{ scriptId: script.id, newStatus: { isReverted: true, isSelected: true } },
],
});
debounceStub.execute();
// assert
expect(changeEvents).to.have.lengthOf(1);
expectEqualSelectedScripts(selection.selectedScripts, expectedSelection);
});
it('applies multiple changes after debouncing period', () => {
// arrange
const debounceStub = new BatchedDebounceStub<DebounceArg>()
.withImmediateDebouncing(false);
const scripts = [new ScriptStub('first'), new ScriptStub('second'), new ScriptStub('third')];
const selection = new DebouncedScriptSelectionBuilder()
.withBatchedDebounce(debounceStub.func)
.withCollection(createCollectionWithScripts(...scripts))
.build();
const changeEvents = watchForChangeEvents(selection);
const expectedSelection = scripts.map((s) => s.toSelectedScript().withRevert(true));
// act
for (const script of scripts) {
selection.processChanges({
changes: [
{ scriptId: script.id, newStatus: { isReverted: true, isSelected: true } },
],
});
}
debounceStub.execute();
// assert
expect(changeEvents).to.have.lengthOf(1);
expectEqualSelectedScripts(selection.selectedScripts, expectedSelection);
});
});
});
});
function createCollectionWithScripts(...scripts: IScript[]): CategoryCollectionStub {
const category = new CategoryStub(1).withScripts(...scripts);
const collection = new CategoryCollectionStub().withAction(category);
return collection;
}
function watchForChangeEvents(
selection: DebouncedScriptSelection,
): ReadonlyArray<readonly SelectedScript[]> {
const changes: Array<readonly SelectedScript[]> = [];
selection.changed.on((s) => changes.push(s));
return changes;
}
type TestScripts = readonly [ScriptStub, ScriptStub, ScriptStub];
type TestScriptSelector = (
allScripts: TestScripts,
) => readonly SelectedScriptStub[] | readonly ScriptStub[];
function setupTestWithPreselectedScripts(options: {
preselect: TestScriptSelector,
}) {
const allScripts: TestScripts = [
new ScriptStub('first-script'),
new ScriptStub('second-script'),
new ScriptStub('third-script'),
];
const preselectedScripts = (() => {
const initialSelection = options.preselect(allScripts);
if (isScriptStubArray(initialSelection)) {
return initialSelection.map((s) => s.toSelectedScript().withRevert(false));
}
return initialSelection;
})();
const unselectedScripts = allScripts.filter(
(s) => !preselectedScripts.map((selected) => selected.id).includes(s.id),
);
const collection = createCollectionWithScripts(...allScripts);
const scriptSelection = new DebouncedScriptSelectionBuilder()
.withSelectedScripts(preselectedScripts)
.withCollection(collection)
.build();
const changeEvents = watchForChangeEvents(scriptSelection);
return {
allScripts,
unselectedScripts,
preselectedScripts,
scriptSelection,
changeEvents,
};
}
function isScriptStubArray(obj: readonly unknown[]): obj is readonly ScriptStub[] {
return obj.length > 0 && obj[0] instanceof ScriptStub;
}
class DebouncedScriptSelectionBuilder {
private collection: ICategoryCollection = new CategoryCollectionStub()
.withSomeActions();
private selectedScripts: readonly SelectedScript[] = [];
private batchedDebounce: DebounceFunction = new BatchedDebounceStub<DebounceArg>()
.withImmediateDebouncing(true)
.func;
public withSelectedScripts(selectedScripts: readonly SelectedScript[]) {
this.selectedScripts = selectedScripts;
return this;
}
public withBatchedDebounce(batchedDebounce: DebounceFunction) {
this.batchedDebounce = batchedDebounce;
return this;
}
public withCollection(collection: ICategoryCollection) {
this.collection = collection;
return this;
}
public build(): DebouncedScriptSelection {
return new DebouncedScriptSelection(
this.collection,
this.selectedScripts,
this.batchedDebounce,
);
}
}

View File

@@ -0,0 +1,46 @@
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
export function expectEqualSelectedScripts(
actual: readonly SelectedScript[],
expected: readonly SelectedScript[],
) {
expectSameScriptIds(actual, expected);
expectSameRevertStates(actual, expected);
}
function expectSameScriptIds(
actual: readonly SelectedScript[],
expected: readonly SelectedScript[],
) {
const existingScriptIds = expected.map((script) => script.id).sort();
const expectedScriptIds = actual.map((script) => script.id).sort();
expect(existingScriptIds).to.deep.equal(expectedScriptIds, [
'Unexpected script IDs.',
`Expected: ${expectedScriptIds.join(', ')}`,
`Actual: ${existingScriptIds.join(', ')}`,
].join('\n'));
}
function expectSameRevertStates(
actual: readonly SelectedScript[],
expected: readonly SelectedScript[],
) {
const scriptsWithDifferentRevertStates = actual
.filter((script) => {
const other = expected.find((existing) => existing.id === script.id);
if (!other) {
throw new Error(`Script "${script.id}" does not exist in expected scripts: ${JSON.stringify(expected, null, '\t')}`);
}
return script.revert !== other.revert;
});
expect(scriptsWithDifferentRevertStates).to.have.lengthOf(0, [
'Scripts with different revert states:',
scriptsWithDifferentRevertStates
.map((s) => [
`Script ID: "${s.id}"`,
`Actual revert state: "${s.revert}"`,
`Expected revert state: "${expected.find((existing) => existing.id === s.id)?.revert ?? 'unknown'}"`,
].map((line) => `\t${line}`).join('\n'))
.join('\n---\n'),
].join('\n'));
}

View File

@@ -1,13 +1,13 @@
import { describe, it, expect } from 'vitest';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { UserSelectedScript } from '@/application/Context/State/Selection/Script/UserSelectedScript';
describe('SelectedScript', () => {
describe('UserSelectedScript', () => {
it('id is same as script id', () => {
// arrange
const expectedId = 'scriptId';
const script = new ScriptStub(expectedId);
const sut = new SelectedScript(script, false);
const sut = new UserSelectedScript(script, false);
// act
const actualId = sut.id;
// assert
@@ -15,13 +15,13 @@ describe('SelectedScript', () => {
});
it('throws when revert is true for irreversible script', () => {
// arrange
const expectedId = 'scriptId';
const script = new ScriptStub(expectedId)
const scriptId = 'irreversibleScriptId';
const expectedError = `The script with ID '${scriptId}' is not reversible and cannot be reverted.`;
const script = new ScriptStub(scriptId)
.withRevertCode(undefined);
// act
// eslint-disable-next-line no-new
function construct() { new SelectedScript(script, true); }
const act = () => new UserSelectedScript(script, true);
// assert
expect(construct).to.throw('cannot revert an irreversible script');
expect(act).to.throw(expectedError);
});
});

View File

@@ -1,463 +0,0 @@
import { describe, it, expect } from 'vitest';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { UserSelectionTestRunner } from './UserSelectionTestRunner';
describe('UserSelection', () => {
describe('ctor', () => {
describe('has nothing with no initial selection', () => {
// arrange
const allScripts = [
new SelectedScriptStub('s1', false),
];
new UserSelectionTestRunner()
.withSelectedScripts([])
.withCategory(1, allScripts.map((s) => s.script))
// act
.run()
// assert
.expectFinalScripts([]);
});
describe('has initial selection', () => {
// arrange
const scripts = [
new SelectedScriptStub('s1', false),
new SelectedScriptStub('s2', false),
];
new UserSelectionTestRunner()
.withSelectedScripts(scripts)
.withCategory(1, scripts.map((s) => s.script))
// act
.run()
// assert
.expectFinalScripts(scripts);
});
});
describe('deselectAll', () => {
describe('removes existing items', () => {
// arrange
const allScripts = [
new SelectedScriptStub('s1', false),
new SelectedScriptStub('s2', false),
new SelectedScriptStub('s3', false),
new SelectedScriptStub('s4', false),
];
const selectedScripts = allScripts.filter(
(s) => ['s1', 's2', 's3'].includes(s.id),
);
new UserSelectionTestRunner()
.withSelectedScripts(selectedScripts)
.withCategory(1, allScripts.map((s) => s.script))
// act
.run((sut) => {
sut.deselectAll();
})
// assert
.expectTotalFiredEvents(1)
.expectFinalScripts([])
.expectFinalScriptsInEvent(0, []);
});
describe('does not notify if nothing is selected', () => {
new UserSelectionTestRunner()
// arrange
.withSelectedScripts([])
// act
.run((sut) => {
sut.deselectAll();
})
// assert
.expectTotalFiredEvents(0);
});
});
describe('selectAll', () => {
describe('selects as expected', () => {
// arrange
const expected = [
new SelectedScriptStub('s1', false),
new SelectedScriptStub('s2', false),
];
new UserSelectionTestRunner()
.withSelectedScripts([])
.withCategory(1, expected.map((s) => s.script))
// act
.run((sut) => {
sut.selectAll();
})
// assert
.expectTotalFiredEvents(1)
.expectFinalScripts(expected)
.expectFinalScriptsInEvent(0, expected);
});
describe('does not notify if nothing new is selected', () => {
const allScripts = [new ScriptStub('s1'), new ScriptStub('s2')];
const selectedScripts = allScripts.map((s) => s.toSelectedScript(false));
new UserSelectionTestRunner()
// arrange
.withSelectedScripts(selectedScripts)
.withCategory(0, allScripts)
// act
.run((sut) => {
sut.selectAll();
})
// assert
.expectTotalFiredEvents(0);
});
});
describe('selectOnly', () => {
describe('selects as expected', () => {
// arrange
const allScripts = [
new SelectedScriptStub('s1', false),
new SelectedScriptStub('s2', false),
new SelectedScriptStub('s3', false),
new SelectedScriptStub('s4', false),
];
const getScripts = (...ids: string[]) => allScripts.filter((s) => ids.includes(s.id));
const testCases = [
{
name: 'adds as expected',
preSelected: getScripts('s1'),
toSelect: getScripts('s1', 's2'),
},
{
name: 'removes as expected',
preSelected: getScripts('s1', 's2', 's3'),
toSelect: getScripts('s1'),
},
{
name: 'adds and removes as expected',
preSelected: getScripts('s1', 's2', 's3'),
toSelect: getScripts('s2', 's3', 's4'),
},
];
for (const testCase of testCases) {
describe(testCase.name, () => {
new UserSelectionTestRunner()
.withSelectedScripts(testCase.preSelected)
.withCategory(1, testCase.toSelect.map((s) => s.script))
// act
.run((sut) => {
sut.selectOnly(testCase.toSelect.map((s) => s.script));
})
// assert
.expectTotalFiredEvents(1)
.expectFinalScripts(testCase.toSelect)
.expectFinalScriptsInEvent(0, testCase.toSelect);
});
}
});
describe('does not notify if selection does not change', () => {
const allScripts = [new ScriptStub('s1'), new ScriptStub('s2'), new ScriptStub('s3')];
const toSelect = [allScripts[0], allScripts[1]];
const preSelected = toSelect.map((s) => s.toSelectedScript(false));
new UserSelectionTestRunner()
// arrange
.withSelectedScripts(preSelected)
.withCategory(0, allScripts)
// act
.run((sut) => {
sut.selectOnly(toSelect);
})
// assert
.expectTotalFiredEvents(0);
});
});
describe('addOrUpdateSelectedScript', () => {
describe('adds when item does not exist', () => {
// arrange
const scripts = [new ScriptStub('s1'), new ScriptStub('s2')];
const expected = [new SelectedScript(scripts[0], false)];
new UserSelectionTestRunner()
.withSelectedScripts([])
.withCategory(1, scripts)
// act
.run((sut) => {
sut.addOrUpdateSelectedScript(scripts[0].id, false);
})
// assert
.expectTotalFiredEvents(1)
.expectFinalScripts(expected)
.expectFinalScriptsInEvent(0, expected);
});
describe('updates when item exists', () => {
// arrange
const scripts = [new ScriptStub('s1'), new ScriptStub('s2')];
const existing = new SelectedScript(scripts[0], false);
const expected = new SelectedScript(scripts[0], true);
new UserSelectionTestRunner()
.withSelectedScripts([existing])
.withCategory(1, scripts)
// act
.run((sut) => {
sut.addOrUpdateSelectedScript(expected.id, expected.revert);
})
// assert
.expectTotalFiredEvents(1)
.expectFinalScripts([expected])
.expectFinalScriptsInEvent(0, [expected]);
});
});
describe('removeAllInCategory', () => {
describe('does nothing when nothing exists', () => {
// arrange
const categoryId = 99;
const scripts = [new ScriptStub('s1'), new ScriptStub('s2')];
new UserSelectionTestRunner()
.withSelectedScripts([])
.withCategory(categoryId, scripts)
// act
.run((sut) => {
sut.removeAllInCategory(categoryId);
})
// assert
.expectTotalFiredEvents(0)
.expectFinalScripts([]);
});
describe('removes all when all exists', () => {
// arrange
const categoryId = 34;
const scripts = [new SelectedScriptStub('s1'), new SelectedScriptStub('s2')];
new UserSelectionTestRunner()
.withSelectedScripts(scripts)
.withCategory(categoryId, scripts.map((s) => s.script))
// act
.run((sut) => {
sut.removeAllInCategory(categoryId);
})
// assert
.expectTotalFiredEvents(1)
.expectFinalScripts([]);
});
describe('removes existing when some exists', () => {
// arrange
const categoryId = 55;
const existing = [new ScriptStub('s1'), new ScriptStub('s2')];
const notExisting = [new ScriptStub('s3'), new ScriptStub('s4')];
new UserSelectionTestRunner()
.withSelectedScripts(existing.map((script) => new SelectedScript(script, false)))
.withCategory(categoryId, [...existing, ...notExisting])
// act
.run((sut) => {
sut.removeAllInCategory(categoryId);
})
// assert
.expectTotalFiredEvents(1)
.expectFinalScripts([]);
});
});
describe('addOrUpdateAllInCategory', () => {
describe('when all already exists', () => {
describe('does nothing if nothing is changed', () => {
// arrange
const categoryId = 55;
const existingScripts = [
new SelectedScriptStub('s1', false),
new SelectedScriptStub('s2', false),
];
new UserSelectionTestRunner()
.withSelectedScripts(existingScripts)
.withCategory(categoryId, existingScripts.map((s) => s.script))
// act
.run((sut) => {
sut.addOrUpdateAllInCategory(categoryId);
})
// assert
.expectTotalFiredEvents(0)
.expectFinalScripts(existingScripts);
});
describe('changes revert status of all', () => {
// arrange
const newStatus = false;
const scripts = [
new SelectedScriptStub('e1', !newStatus),
new SelectedScriptStub('e2', !newStatus),
new SelectedScriptStub('e3', newStatus),
];
const expectedScripts = scripts.map((s) => new SelectedScript(s.script, newStatus));
const categoryId = 31;
new UserSelectionTestRunner()
.withSelectedScripts(scripts)
.withCategory(categoryId, scripts.map((s) => s.script))
// act
.run((sut) => {
sut.addOrUpdateAllInCategory(categoryId, newStatus);
})
// assert
.expectTotalFiredEvents(1)
.expectFinalScripts(expectedScripts)
.expectFinalScriptsInEvent(0, expectedScripts);
});
});
describe('when nothing exists; adds all with given revert status', () => {
const revertStatuses = [true, false];
for (const revertStatus of revertStatuses) {
describe(`when revert status is ${revertStatus}`, () => {
// arrange
const categoryId = 1;
const scripts = [
new SelectedScriptStub('s1', !revertStatus),
new SelectedScriptStub('s2', !revertStatus),
];
const expected = scripts.map((s) => new SelectedScript(s.script, revertStatus));
new UserSelectionTestRunner()
.withSelectedScripts([])
.withCategory(categoryId, scripts.map((s) => s.script))
// act
.run((sut) => {
sut.addOrUpdateAllInCategory(categoryId, revertStatus);
})
// assert
.expectTotalFiredEvents(1)
.expectFinalScripts(expected)
.expectFinalScriptsInEvent(0, expected);
});
}
});
describe('when some exists; changes revert status of all', () => {
// arrange
const newStatus = true;
const existing = [
new SelectedScriptStub('e1', true),
new SelectedScriptStub('e2', false),
];
const notExisting = [
new SelectedScriptStub('n3', true),
new SelectedScriptStub('n4', false),
];
const allScripts = [...existing, ...notExisting];
const expectedScripts = allScripts.map((s) => new SelectedScript(s.script, newStatus));
const categoryId = 77;
new UserSelectionTestRunner()
.withSelectedScripts(existing)
.withCategory(categoryId, allScripts.map((s) => s.script))
// act
.run((sut) => {
sut.addOrUpdateAllInCategory(categoryId, newStatus);
})
// assert
.expectTotalFiredEvents(1)
.expectFinalScripts(expectedScripts)
.expectFinalScriptsInEvent(0, expectedScripts);
});
});
describe('isSelected', () => {
it('returns false when not selected', () => {
// arrange
const selectedScript = new ScriptStub('selected');
const notSelectedScript = new ScriptStub('not selected');
const collection = new CategoryCollectionStub()
.withAction(new CategoryStub(1)
.withScripts(selectedScript, notSelectedScript));
const sut = new UserSelection(collection, [new SelectedScript(selectedScript, false)]);
// act
const actual = sut.isSelected(notSelectedScript.id);
// assert
expect(actual).to.equal(false);
});
it('returns true when selected', () => {
// arrange
const selectedScript = new ScriptStub('selected');
const notSelectedScript = new ScriptStub('not selected');
const collection = new CategoryCollectionStub()
.withAction(new CategoryStub(1)
.withScripts(selectedScript, notSelectedScript));
const sut = new UserSelection(collection, [new SelectedScript(selectedScript, false)]);
// act
const actual = sut.isSelected(selectedScript.id);
// assert
expect(actual).to.equal(true);
});
});
describe('category state', () => {
describe('when no scripts are selected', () => {
// arrange
const category = new CategoryStub(1)
.withScriptIds('non-selected-script-1', 'non-selected-script-2');
const collection = new CategoryCollectionStub().withAction(category);
const sut = new UserSelection(collection, []);
it('areAllSelected returns false', () => {
// act
const actual = sut.areAllSelected(category);
// assert
expect(actual).to.equal(false);
});
it('isAnySelected returns false', () => {
// act
const actual = sut.isAnySelected(category);
// assert
expect(actual).to.equal(false);
});
});
describe('when no subscript exists in selected scripts', () => {
// arrange
const category = new CategoryStub(1)
.withScriptIds('non-selected-script-1', 'non-selected-script-2');
const selectedScript = new ScriptStub('selected');
const collection = new CategoryCollectionStub()
.withAction(category)
.withAction(new CategoryStub(22).withScript(selectedScript));
const sut = new UserSelection(collection, [new SelectedScript(selectedScript, false)]);
it('areAllSelected returns false', () => {
// act
const actual = sut.areAllSelected(category);
// assert
expect(actual).to.equal(false);
});
it('isAnySelected returns false', () => {
// act
const actual = sut.isAnySelected(category);
// assert
expect(actual).to.equal(false);
});
});
describe('when one of the scripts are selected', () => {
// arrange
const selectedScript = new ScriptStub('selected');
const category = new CategoryStub(1)
.withScriptIds('non-selected-script-1', 'non-selected-script-2')
.withCategory(new CategoryStub(12).withScript(selectedScript));
const collection = new CategoryCollectionStub().withAction(category);
const sut = new UserSelection(collection, [new SelectedScript(selectedScript, false)]);
it('areAllSelected returns false', () => {
// act
const actual = sut.areAllSelected(category);
// assert
expect(actual).to.equal(false);
});
it('isAnySelected returns true', () => {
// act
const actual = sut.isAnySelected(category);
// assert
expect(actual).to.equal(true);
});
});
describe('when all scripts are selected', () => {
// arrange
const firstSelectedScript = new ScriptStub('selected1');
const secondSelectedScript = new ScriptStub('selected2');
const category = new CategoryStub(1)
.withScript(firstSelectedScript)
.withCategory(new CategoryStub(12).withScript(secondSelectedScript));
const collection = new CategoryCollectionStub().withAction(category);
const selectedScripts = [firstSelectedScript, secondSelectedScript]
.map((s) => new SelectedScript(s, false));
const sut = new UserSelection(collection, selectedScripts);
it('areAllSelected returns true', () => {
// act
const actual = sut.areAllSelected(category);
// assert
expect(actual).to.equal(true);
});
it('isAnySelected returns true', () => {
// act
const actual = sut.isAnySelected(category);
// assert
expect(actual).to.equal(true);
});
});
});
});

View File

@@ -0,0 +1,133 @@
import { describe, it } from 'vitest';
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { UserSelectionFacade } from '@/application/Context/State/Selection/UserSelectionFacade';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
import type { ScriptsFactory, CategoriesFactory } from '@/application/Context/State/Selection/UserSelectionFacade';
import { ScriptSelectionStub } from '@tests/unit/shared/Stubs/ScriptSelectionStub';
import { CategorySelectionStub } from '@tests/unit/shared/Stubs/CategorySelectionStub';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { ScriptSelection } from '@/application/Context/State/Selection/Script/ScriptSelection';
describe('UserSelectionFacade', () => {
describe('ctor', () => {
describe('scripts', () => {
it('constructs with expected collection', () => {
// arrange
const expectedCollection = new CategoryCollectionStub();
let actualCollection: ICategoryCollection | undefined;
const factoryMock: ScriptsFactory = (collection) => {
actualCollection = collection;
return new ScriptSelectionStub();
};
const builder = new UserSelectionFacadeBuilder()
.withCollection(expectedCollection)
.withScriptsFactory(factoryMock);
// act
builder.construct();
// assert
expectExists(actualCollection);
expect(actualCollection).to.equal(expectedCollection);
});
it('constructs with expected selected scripts', () => {
// arrange
const expectedScripts: readonly SelectedScript[] = [
new SelectedScriptStub(new ScriptStub('1')),
];
let actualScripts: readonly SelectedScript[] | undefined;
const factoryMock: ScriptsFactory = (_, scripts) => {
actualScripts = scripts;
return new ScriptSelectionStub();
};
const builder = new UserSelectionFacadeBuilder()
.withSelectedScripts(expectedScripts)
.withScriptsFactory(factoryMock);
// act
builder.construct();
// assert
expectExists(actualScripts);
expect(actualScripts).to.equal(expectedScripts);
});
});
describe('categories', () => {
it('constructs with expected collection', () => {
// arrange
const expectedCollection = new CategoryCollectionStub();
let actualCollection: ICategoryCollection | undefined;
const factoryMock: CategoriesFactory = (_, collection) => {
actualCollection = collection;
return new CategorySelectionStub();
};
const builder = new UserSelectionFacadeBuilder()
.withCollection(expectedCollection)
.withCategoriesFactory(factoryMock);
// act
builder.construct();
// assert
expectExists(actualCollection);
expect(actualCollection).to.equal(expectedCollection);
});
it('constructs with expected scripts', () => {
// arrange
const expectedScriptSelection = new ScriptSelectionStub();
let actualScriptsSelection: ScriptSelection | undefined;
const categoriesFactoryMock: CategoriesFactory = (selection) => {
actualScriptsSelection = selection;
return new CategorySelectionStub();
};
const scriptsFactoryMock: ScriptsFactory = () => {
return expectedScriptSelection;
};
const builder = new UserSelectionFacadeBuilder()
.withCategoriesFactory(categoriesFactoryMock)
.withScriptsFactory(scriptsFactoryMock);
// act
builder.construct();
// assert
expectExists(actualScriptsSelection);
expect(actualScriptsSelection).to.equal(expectedScriptSelection);
});
});
});
});
class UserSelectionFacadeBuilder {
private collection: ICategoryCollection = new CategoryCollectionStub();
private selectedScripts: readonly SelectedScript[] = [];
private scriptsFactory: ScriptsFactory = () => new ScriptSelectionStub();
private categoriesFactory: CategoriesFactory = () => new CategorySelectionStub();
public withCollection(collection: ICategoryCollection): this {
this.collection = collection;
return this;
}
public withSelectedScripts(selectedScripts: readonly SelectedScript[]): this {
this.selectedScripts = selectedScripts;
return this;
}
public withScriptsFactory(scriptsFactory: ScriptsFactory): this {
this.scriptsFactory = scriptsFactory;
return this;
}
public withCategoriesFactory(categoriesFactory: CategoriesFactory): this {
this.categoriesFactory = categoriesFactory;
return this;
}
public construct(): UserSelectionFacade {
return new UserSelectionFacade(
this.collection,
this.selectedScripts,
this.scriptsFactory,
this.categoriesFactory,
);
}
}

View File

@@ -1,88 +0,0 @@
import { it, expect } from 'vitest';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
import { IScript } from '@/domain/IScript';
export class UserSelectionTestRunner {
private readonly collection = new CategoryCollectionStub();
private existingScripts: readonly SelectedScript[] = [];
private events: Array<readonly SelectedScript[]> = [];
private sut: UserSelection;
public withCategory(categoryId: number, scripts: readonly IScript[]) {
const category = new CategoryStub(categoryId)
.withScripts(...scripts);
this.collection
.withAction(category);
return this;
}
public withSelectedScripts(existingScripts: readonly SelectedScript[]) {
this.existingScripts = existingScripts;
return this;
}
public run(runner?: (sut: UserSelection) => void) {
this.sut = this.createSut();
if (runner) {
runner(this.sut);
}
return this;
}
public expectTotalFiredEvents(amount: number) {
const testName = amount === 0 ? 'does not fire changed event' : `fires changed event ${amount} times`;
it(testName, () => {
expect(this.events).to.have.lengthOf(amount);
});
return this;
}
public expectFinalScripts(finalScripts: readonly SelectedScript[]) {
expectSameScripts(finalScripts, this.sut.selectedScripts);
return this;
}
public expectFinalScriptsInEvent(eventIndex: number, finalScripts: readonly SelectedScript[]) {
expectSameScripts(this.events[eventIndex], finalScripts);
return this;
}
private createSut(): UserSelection {
const sut = new UserSelection(this.collection, this.existingScripts);
sut.changed.on((s) => this.events.push(s));
return sut;
}
}
function expectSameScripts(actual: readonly SelectedScript[], expected: readonly SelectedScript[]) {
it('has same expected scripts', () => {
const existingScriptIds = expected.map((script) => script.id).sort();
const expectedScriptIds = actual.map((script) => script.id).sort();
expect(existingScriptIds).to.deep.equal(expectedScriptIds);
});
it('has expected revert state', () => {
const scriptsWithDifferentStatus = actual
.filter((script) => {
const other = expected.find((existing) => existing.id === script.id);
if (!other) {
throw new Error(`Script "${script.id}" does not exist in expected scripts: ${JSON.stringify(expected, null, '\t')}`);
}
return script.revert !== other.revert;
});
expect(scriptsWithDifferentStatus).to.have.lengthOf(
0,
`Scripts with different statuses:\n${
scriptsWithDifferentStatus
.map((s) => `[id: ${s.id}, actual status: ${s.revert}, `
+ `expected status: ${expected.find((existing) => existing.id === s.id)?.revert ?? 'unknown'}]`)
.join(' , ')
}`,
);
});
}