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