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

@@ -1,106 +1,167 @@
import { describe, it, expect } from 'vitest';
import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
import { ApplicationCode } from '@/application/Context/State/Code/ApplicationCode';
import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub';
import { CategoryCollectionState } from '@/application/Context/State/CategoryCollectionState';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { IScript } from '@/domain/IScript';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
import { ScriptingDefinitionStub } from '@tests/unit/shared/Stubs/ScriptingDefinitionStub';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { ApplicationCodeStub } from '@tests/unit/shared/Stubs/ApplicationCodeStub';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { ReadonlyScriptSelection } from '@/application/Context/State/Selection/Script/ScriptSelection';
import { ScriptSelectionStub } from '@tests/unit/shared/Stubs/ScriptSelectionStub';
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { UserFilterStub } from '@tests/unit/shared/Stubs/UserFilterStub';
import type { CodeFactory, FilterFactory, SelectionFactory } from '@/application/Context/State/CategoryCollectionState';
describe('CategoryCollectionState', () => {
describe('code', () => {
it('initialized with empty code', () => {
// arrange
const collection = new CategoryCollectionStub();
const sut = new CategoryCollectionState(collection);
// act
const code = sut.code.current;
// assert
expect(!code);
});
it('reacts to selection changes as expected', () => {
it('uses the correct scripting definition', () => {
// arrange
const expectedScripting = new ScriptingDefinitionStub();
const collection = new CategoryCollectionStub()
.withAction(new CategoryStub(0).withScriptIds('scriptId'));
const selectionStub = new UserSelection(collection, []);
const expectedCodeGenerator = new ApplicationCode(selectionStub, collection.scripting);
selectionStub.selectAll();
const expectedCode = expectedCodeGenerator.current;
.withScripting(expectedScripting);
let actualScripting: IScriptingDefinition | undefined;
const codeFactoryMock: CodeFactory = (_, scripting) => {
actualScripting = scripting;
return new ApplicationCodeStub();
};
// act
const sut = new CategoryCollectionState(collection);
sut.selection.selectAll();
const actualCode = sut.code.current;
new CategoryCollectionStateBuilder()
.withCollection(collection)
.withCodeFactory(codeFactoryMock)
.build();
// assert
expect(actualCode).to.equal(expectedCode);
expectExists(actualScripting);
expect(actualScripting).to.equal(expectedScripting);
});
it('initializes with the expected script selection', () => {
// arrange
const expectedScriptSelection = new ScriptSelectionStub();
const selectionFactoryMock: SelectionFactory = () => {
return new UserSelectionStub().withScripts(expectedScriptSelection);
};
let actualScriptSelection: ReadonlyScriptSelection | undefined;
const codeFactoryMock: CodeFactory = (scriptSelection) => {
actualScriptSelection = scriptSelection;
return new ApplicationCodeStub();
};
// act
new CategoryCollectionStateBuilder()
.withCodeFactory(codeFactoryMock)
.withSelectionFactory(selectionFactoryMock)
.build();
// assert
expectExists(actualScriptSelection);
expect(actualScriptSelection).to.equal(expectedScriptSelection);
});
});
describe('os', () => {
it('same as its collection', () => {
it('matches the operating system of the collection', () => {
// arrange
const expected = OperatingSystem.macOS;
const collection = new CategoryCollectionStub()
.withOs(expected);
// act
const sut = new CategoryCollectionState(collection);
const sut = new CategoryCollectionStateBuilder()
.withCollection(collection)
.build();
// assert
const actual = sut.os;
expect(expected).to.equal(actual);
});
});
describe('selection', () => {
it('initialized with no selection', () => {
it('initializes with empty scripts', () => {
// arrange
const collection = new CategoryCollectionStub();
const sut = new CategoryCollectionState(collection);
const expectedScripts = [];
let actualScripts: readonly SelectedScript[] | undefined;
const selectionFactoryMock: SelectionFactory = (_, scripts) => {
actualScripts = scripts;
return new UserSelectionStub();
};
// act
const actual = sut.selection.selectedScripts.length;
new CategoryCollectionStateBuilder()
.withSelectionFactory(selectionFactoryMock)
.build();
// assert
expect(actual).to.equal(0);
expectExists(actualScripts);
expect(actualScripts).to.deep.equal(expectedScripts);
});
it('can select a script from current collection', () => {
it('initializes with the provided collection', () => {
// arrange
const expectedScript = new ScriptStub('scriptId');
const collection = new CategoryCollectionStub()
.withAction(new CategoryStub(0).withScript(expectedScript));
const sut = new CategoryCollectionState(collection);
const expectedCollection = new CategoryCollectionStub();
let actualCollection: ICategoryCollection | undefined;
const selectionFactoryMock: SelectionFactory = (collection) => {
actualCollection = collection;
return new UserSelectionStub();
};
// act
sut.selection.selectAll();
new CategoryCollectionStateBuilder()
.withCollection(expectedCollection)
.withSelectionFactory(selectionFactoryMock)
.build();
// assert
expect(sut.selection.selectedScripts.length).to.equal(1);
expect(sut.selection.isSelected(expectedScript.id)).to.equal(true);
expectExists(actualCollection);
expect(actualCollection).to.equal(expectedCollection);
});
});
describe('filter', () => {
it('initialized with an empty filter', () => {
it('initializes with the provided collection for filtering', () => {
// arrange
const collection = new CategoryCollectionStub();
const sut = new CategoryCollectionState(collection);
const expectedCollection = new CategoryCollectionStub();
let actualCollection: ICategoryCollection | undefined;
const filterFactoryMock: FilterFactory = (collection) => {
actualCollection = collection;
return new UserFilterStub();
};
// act
const actual = sut.filter.currentFilter;
new CategoryCollectionStateBuilder()
.withCollection(expectedCollection)
.withFilterFactory(filterFactoryMock)
.build();
// assert
expect(actual).to.equal(undefined);
});
it('can match a script from current collection', () => {
// arrange
const scriptNameFilter = 'scriptName';
const expectedScript = new ScriptStub('scriptId')
.withName(scriptNameFilter);
const collection = new CategoryCollectionStub()
.withAction(new CategoryStub(0).withScript(expectedScript));
const sut = new CategoryCollectionState(collection);
// act
let actualScript: IScript | undefined;
sut.filter.filterChanged.on((result) => {
result.visit({
onApply: (filter) => {
[actualScript] = filter.scriptMatches;
},
});
});
sut.filter.applyFilter(scriptNameFilter);
// assert
expect(expectedScript).to.equal(actualScript);
expectExists(expectedCollection);
expect(expectedCollection).to.equal(actualCollection);
});
});
});
class CategoryCollectionStateBuilder {
private collection: ICategoryCollection = new CategoryCollectionStub();
private codeFactory: CodeFactory = () => new ApplicationCodeStub();
private selectionFactory: SelectionFactory = () => new UserSelectionStub();
private filterFactory: FilterFactory = () => new UserFilterStub();
public withCollection(collection: ICategoryCollection): this {
this.collection = collection;
return this;
}
public withCodeFactory(codeFactory: CodeFactory): this {
this.codeFactory = codeFactory;
return this;
}
public withSelectionFactory(selectionFactory: SelectionFactory): this {
this.selectionFactory = selectionFactory;
return this;
}
public withFilterFactory(filterFactory: FilterFactory): this {
this.filterFactory = filterFactory;
return this;
}
public build() {
return new CategoryCollectionState(
this.collection,
this.selectionFactory,
this.codeFactory,
this.filterFactory,
);
}
}

View File

@@ -1,7 +1,5 @@
import { describe, it, expect } from 'vitest';
import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
import { ApplicationCode } from '@/application/Context/State/Code/ApplicationCode';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { ICodeChangedEvent } from '@/application/Context/State/Code/Event/ICodeChangedEvent';
import { IUserScriptGenerator } from '@/application/Context/State/Code/Generation/IUserScriptGenerator';
import { CodePosition } from '@/application/Context/State/Code/Position/CodePosition';
@@ -9,16 +7,18 @@ import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePo
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { IUserScript } from '@/application/Context/State/Code/Generation/IUserScript';
import { ScriptingDefinitionStub } from '@tests/unit/shared/Stubs/ScriptingDefinitionStub';
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { ScriptSelectionStub } from '@tests/unit/shared/Stubs/ScriptSelectionStub';
describe('ApplicationCode', () => {
describe('ctor', () => {
it('empty when selection is empty', () => {
// arrange
const selection = new UserSelection(new CategoryCollectionStub(), []);
const selectedScripts = [];
const selection = new ScriptSelectionStub()
.withSelectedScripts(selectedScripts);
const definition = new ScriptingDefinitionStub();
const sut = new ApplicationCode(selection, definition);
// act
@@ -29,10 +29,9 @@ describe('ApplicationCode', () => {
it('generates code from script generator when selection is not empty', () => {
// arrange
const scripts = [new ScriptStub('first'), new ScriptStub('second')];
const collection = new CategoryCollectionStub()
.withAction(new CategoryStub(1).withScripts(...scripts));
const selectedScripts = scripts.map((script) => script.toSelectedScript());
const selection = new UserSelection(collection, selectedScripts);
const selection = new ScriptSelectionStub()
.withSelectedScripts(selectedScripts);
const definition = new ScriptingDefinitionStub();
const expected: IUserScript = {
code: 'expected-code',
@@ -53,10 +52,9 @@ describe('ApplicationCode', () => {
// arrange
let signaled: ICodeChangedEvent | undefined;
const scripts = [new ScriptStub('first'), new ScriptStub('second')];
const collection = new CategoryCollectionStub()
.withAction(new CategoryStub(1).withScripts(...scripts));
const scriptsToSelect = scripts.map((script) => new SelectedScript(script, false));
const selection = new UserSelection(collection, scriptsToSelect);
const selectedScripts = scripts.map((script) => script.toSelectedScript());
const selection = new ScriptSelectionStub()
.withSelectedScripts(selectedScripts);
const definition = new ScriptingDefinitionStub();
const sut = new ApplicationCode(selection, definition);
sut.changed.on((code) => {
@@ -73,17 +71,18 @@ describe('ApplicationCode', () => {
// arrange
let signaled: ICodeChangedEvent | undefined;
const scripts = [new ScriptStub('first'), new ScriptStub('second')];
const collection = new CategoryCollectionStub()
.withAction(new CategoryStub(1).withScripts(...scripts));
const scriptsToSelect = scripts.map((script) => new SelectedScript(script, false));
const selection = new UserSelection(collection, scriptsToSelect);
const selectedScripts = scripts.map(
(script) => script.toSelectedScript().withRevert(false),
);
const selection = new ScriptSelectionStub()
.withSelectedScripts(selectedScripts);
const definition = new ScriptingDefinitionStub();
const sut = new ApplicationCode(selection, definition);
sut.changed.on((code) => {
signaled = code;
});
// act
selection.changed.notify(scripts.map((s) => new SelectedScript(s, false)));
selection.changed.notify(selectedScripts);
// assert
expectExists(signaled);
expect(signaled.code).to.have.length.greaterThan(0);
@@ -94,8 +93,8 @@ describe('ApplicationCode', () => {
it('sends scripting definition to generator', () => {
// arrange
const expectedDefinition = new ScriptingDefinitionStub();
const collection = new CategoryCollectionStub();
const selection = new UserSelection(collection, []);
const selection = new ScriptSelectionStub()
.withSelectedScripts([]);
const generatorMock: IUserScriptGenerator = {
buildCode: (_, definition) => {
if (definition !== expectedDefinition) {
@@ -118,13 +117,12 @@ describe('ApplicationCode', () => {
// arrange
const expectedDefinition = new ScriptingDefinitionStub();
const scripts = [new ScriptStub('first'), new ScriptStub('second')];
const collection = new CategoryCollectionStub()
.withAction(new CategoryStub(1).withScripts(...scripts));
const scriptsToSelect = scripts.map((script) => new SelectedScript(script, false));
const selection = new UserSelection(collection, scriptsToSelect);
const selectedScripts = scripts.map((script) => script.toSelectedScript());
const selection = new ScriptSelectionStub()
.withSelectedScripts(selectedScripts);
const generatorMock: IUserScriptGenerator = {
buildCode: (selectedScripts) => {
if (JSON.stringify(selectedScripts) !== JSON.stringify(scriptsToSelect)) {
buildCode: (actualScripts) => {
if (JSON.stringify(actualScripts) !== JSON.stringify(selectedScripts)) {
throw new Error('Unexpected scripts');
}
return {
@@ -136,7 +134,7 @@ describe('ApplicationCode', () => {
// eslint-disable-next-line no-new
new ApplicationCode(selection, expectedDefinition, generatorMock);
// act
const act = () => selection.changed.notify(scriptsToSelect);
const act = () => selection.changed.notify(selectedScripts);
// assert
expect(act).to.not.throw();
});
@@ -144,16 +142,17 @@ describe('ApplicationCode', () => {
// arrange
let signaled: ICodeChangedEvent | undefined;
const scripts = [new ScriptStub('first'), new ScriptStub('second')];
const collection = new CategoryCollectionStub()
.withAction(new CategoryStub(1).withScripts(...scripts));
const scriptsToSelect = scripts.map((script) => new SelectedScript(script, false));
const selection = new UserSelection(collection, scriptsToSelect);
const selectedScripts = scripts.map(
(script) => script.toSelectedScript().withRevert(false),
);
const selection = new ScriptSelectionStub()
.withSelectedScripts(selectedScripts);
const scriptingDefinition = new ScriptingDefinitionStub();
const totalLines = 20;
const expected = new Map<SelectedScript, ICodePosition>(
[
[scriptsToSelect[0], new CodePosition(0, totalLines / 2)],
[scriptsToSelect[1], new CodePosition(totalLines / 2, totalLines)],
[selectedScripts[0], new CodePosition(0, totalLines / 2)],
[selectedScripts[1], new CodePosition(totalLines / 2, totalLines)],
],
);
const generatorMock: IUserScriptGenerator = {
@@ -169,27 +168,27 @@ describe('ApplicationCode', () => {
signaled = code;
});
// act
selection.changed.notify(scriptsToSelect);
selection.changed.notify(selectedScripts);
// assert
expectExists(signaled);
expect(signaled.getScriptPositionInCode(scripts[0]))
.to.deep.equal(expected.get(scriptsToSelect[0]));
.to.deep.equal(expected.get(selectedScripts[0]));
expect(signaled.getScriptPositionInCode(scripts[1]))
.to.deep.equal(expected.get(scriptsToSelect[1]));
.to.deep.equal(expected.get(selectedScripts[1]));
});
});
});
});
interface IScriptGenerationParameters {
scripts: readonly SelectedScript[];
definition: IScriptingDefinition;
interface ScriptGenerationParameters {
readonly scripts: readonly SelectedScript[];
readonly definition: IScriptingDefinition;
}
class UserScriptGeneratorMock implements IUserScriptGenerator {
private prePlanned = new Map<IScriptGenerationParameters, IUserScript>();
private prePlanned = new Map<ScriptGenerationParameters, IUserScript>();
public plan(
parameters: IScriptGenerationParameters,
parameters: ScriptGenerationParameters,
result: IUserScript,
): UserScriptGeneratorMock {
this.prePlanned.set(parameters, result);

View File

@@ -1,10 +1,10 @@
import { describe, it, expect } from 'vitest';
import { CodeChangedEvent } from '@/application/Context/State/Code/Event/CodeChangedEvent';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
import { CodePosition } from '@/application/Context/State/Code/Position/CodePosition';
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
describe('CodeChangedEvent', () => {
describe('ctor', () => {
@@ -15,8 +15,8 @@ describe('CodeChangedEvent', () => {
const nonExistingLine1 = 2;
const nonExistingLine2 = 31;
const newScripts = new Map<SelectedScript, ICodePosition>([
[new SelectedScriptStub('1'), new CodePosition(0, nonExistingLine1)],
[new SelectedScriptStub('2'), new CodePosition(0, nonExistingLine2)],
[new SelectedScriptStub(new ScriptStub('1')), new CodePosition(0, nonExistingLine1)],
[new SelectedScriptStub(new ScriptStub('2')), new CodePosition(0, nonExistingLine2)],
]);
// act
let errorText = '';
@@ -47,7 +47,7 @@ describe('CodeChangedEvent', () => {
for (const testCase of testCases) {
it(testCase.name, () => {
const newScripts = new Map<SelectedScript, ICodePosition>([
[new SelectedScriptStub('1'), testCase.position],
[new SelectedScriptStub(new ScriptStub('1')), testCase.position],
]);
// act
const act = () => new CodeChangedEventBuilder()
@@ -76,12 +76,15 @@ describe('CodeChangedEvent', () => {
it('returns new scripts when scripts are added', () => {
// arrange
const expected = [new ScriptStub('3'), new ScriptStub('4')];
const initialScripts = [new SelectedScriptStub('1'), new SelectedScriptStub('2')];
const initialScripts = [
new SelectedScriptStub(new ScriptStub('1')),
new SelectedScriptStub(new ScriptStub('2')),
];
const newScripts = new Map<SelectedScript, ICodePosition>([
[initialScripts[0], new CodePosition(0, 1)],
[initialScripts[1], new CodePosition(0, 1)],
[new SelectedScript(expected[0], false), new CodePosition(0, 1)],
[new SelectedScript(expected[1], false), new CodePosition(0, 1)],
[new SelectedScriptStub(expected[0]).withRevert(false), new CodePosition(0, 1)],
[new SelectedScriptStub(expected[1]).withRevert(false), new CodePosition(0, 1)],
]);
const sut = new CodeChangedEventBuilder()
.withOldScripts(initialScripts)
@@ -98,8 +101,13 @@ describe('CodeChangedEvent', () => {
describe('removedScripts', () => {
it('returns removed scripts when script are removed', () => {
// arrange
const existingScripts = [new SelectedScriptStub('0'), new SelectedScriptStub('1')];
const removedScripts = [new SelectedScriptStub('2')];
const existingScripts = [
new SelectedScriptStub(new ScriptStub('0')),
new SelectedScriptStub(new ScriptStub('1')),
];
const removedScripts = [
new SelectedScriptStub(new ScriptStub('2')),
];
const initialScripts = [...existingScripts, ...removedScripts];
const newScripts = new Map<SelectedScript, ICodePosition>([
[initialScripts[0], new CodePosition(0, 1)],
@@ -119,10 +127,17 @@ describe('CodeChangedEvent', () => {
describe('changedScripts', () => {
it('returns changed scripts when scripts are changed', () => {
// arrange
const initialScripts = [new SelectedScriptStub('1', false), new SelectedScriptStub('2', false)];
const changedScripts = [
new ScriptStub('scripts-with-changed-selection-1'),
new ScriptStub('scripts-with-changed-selection-2'),
];
const initialScripts = [
new SelectedScriptStub(changedScripts[0]).withRevert(false),
new SelectedScriptStub(changedScripts[1]).withRevert(false),
];
const newScripts = new Map<SelectedScript, ICodePosition>([
[new SelectedScriptStub('1', true), new CodePosition(0, 1)],
[new SelectedScriptStub('2', false), new CodePosition(0, 1)],
[new SelectedScriptStub(changedScripts[0]).withRevert(true), new CodePosition(0, 1)],
[new SelectedScriptStub(changedScripts[1]).withRevert(false), new CodePosition(0, 1)],
]);
const sut = new CodeChangedEventBuilder()
.withOldScripts(initialScripts)
@@ -139,7 +154,7 @@ describe('CodeChangedEvent', () => {
it('returns true when empty', () => {
// arrange
const newScripts = new Map<SelectedScript, ICodePosition>();
const oldScripts = [new SelectedScriptStub('1', false)];
const oldScripts = [new SelectedScriptStub(new ScriptStub('1')).withRevert(false)];
const sut = new CodeChangedEventBuilder()
.withOldScripts(oldScripts)
.withNewScripts(newScripts)
@@ -151,7 +166,7 @@ describe('CodeChangedEvent', () => {
});
it('returns false when not empty', () => {
// arrange
const oldScripts = [new SelectedScriptStub('1')];
const oldScripts = [new SelectedScriptStub(new ScriptStub('1'))];
const newScripts = new Map<SelectedScript, ICodePosition>([
[oldScripts[0], new CodePosition(0, 1)],
]);
@@ -182,7 +197,7 @@ describe('CodeChangedEvent', () => {
const script = new ScriptStub('1');
const expected = new CodePosition(0, 1);
const newScripts = new Map<SelectedScript, ICodePosition>([
[new SelectedScript(script, false), expected],
[new SelectedScriptStub(script).withRevert(false), expected],
]);
const sut = new CodeChangedEventBuilder()
.withNewScripts(newScripts)

View File

@@ -1,12 +1,12 @@
import { describe, it, expect } from 'vitest';
import { UserScriptGenerator } from '@/application/Context/State/Code/Generation/UserScriptGenerator';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { ICodeBuilderFactory } from '@/application/Context/State/Code/Generation/ICodeBuilderFactory';
import { ICodeBuilder } from '@/application/Context/State/Code/Generation/ICodeBuilder';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { ScriptingDefinitionStub } from '@tests/unit/shared/Stubs/ScriptingDefinitionStub';
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
describe('UserScriptGenerator', () => {
describe('scriptingDefinition', () => {
@@ -94,7 +94,7 @@ describe('UserScriptGenerator', () => {
const scriptName = 'test non-revert script';
const scriptCode = 'REM nop';
const script = new ScriptStub('id').withName(scriptName).withCode(scriptCode);
const selectedScripts = [new SelectedScript(script, false)];
const selectedScripts = [new SelectedScriptStub(script).withRevert(false)];
const definition = new ScriptingDefinitionStub();
// act
const actual = sut.buildCode(selectedScripts, definition);
@@ -113,7 +113,8 @@ describe('UserScriptGenerator', () => {
const script = new ScriptStub('id')
.withName(scriptName)
.withRevertCode(scriptCode)
.toSelectedScript(true);
.toSelectedScript()
.withRevert(true);
const definition = new ScriptingDefinitionStub();
// act
const actual = sut.buildCode([script], definition);
@@ -127,10 +128,9 @@ describe('UserScriptGenerator', () => {
const expectedError = 'Reverted script lacks revert code.';
const sut = new UserScriptGenerator();
const script = new ScriptStub('id')
.toSelectedScript(true);
// Hack until SelectedScript is interface:
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(script.script.code as any).revert = emptyRevertCode;
.withRevertCode(emptyRevertCode)
.toSelectedScript()
.withRevert(true);
const definition = new ScriptingDefinitionStub();
// act
const act = () => sut.buildCode([script], definition);
@@ -181,7 +181,8 @@ describe('UserScriptGenerator', () => {
const selectedScript = new ScriptStub('script-id')
.withName('script')
.withCode(testCase.scriptCode)
.toSelectedScript(false);
.toSelectedScript()
.withRevert(false);
// act
const actual = sut.buildCode([selectedScript], definition);
// expect

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(' , ')
}`,
);
});
}