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,5 +1,5 @@
// eslint-disable-next-line max-classes-per-file
import { waitForHeaderBrandTitle } from './shared/ApplicationLoad';
import { getHeaderBrandTitle } from './support/interactions/header';
interface Stoppable {
stop(): void;
@@ -175,7 +175,7 @@ enum ApplicationLoadStep {
const checkpoints: Record<ApplicationLoadStep, () => void> = {
[ApplicationLoadStep.IndexHtmlLoaded]: () => cy.get('#app').should('be.visible'),
[ApplicationLoadStep.AppVueLoaded]: () => cy.get('.app__wrapper').should('be.visible'),
[ApplicationLoadStep.HeaderBrandTitleLoaded]: () => waitForHeaderBrandTitle(),
[ApplicationLoadStep.HeaderBrandTitleLoaded]: () => getHeaderBrandTitle(),
};
class ContinuousRunner implements Stoppable {

View File

@@ -0,0 +1,47 @@
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { openCard } from './support/interactions/card';
describe('script selection highlighting', () => {
it('highlights more when multiple scripts are selected', () => {
// Regression test for a bug where selecting multiple scripts only highlighted the last one.
cy.visit('/');
selectLastScript();
getCurrentHighlightRange((lastScriptHighlightRange) => {
cy.log(`Highlight height for last script: ${lastScriptHighlightRange}`);
cy.visit('/');
selectAllScripts();
getCurrentHighlightRange((allScriptsHighlightRange) => {
cy.log(`Highlight height for all scripts: ${allScriptsHighlightRange}`);
expect(allScriptsHighlightRange).to.be.greaterThan(lastScriptHighlightRange);
});
});
});
});
function selectLastScript() {
openCard({
cardIndex: -1, // last card
});
cy.get('.node')
.last()
.click({ force: true });
}
function selectAllScripts() {
cy.contains('span', 'All')
.click();
}
function getCurrentHighlightRange(
callback: (highlightedRange: number) => void,
) {
cy
.get('#codeEditor')
.invoke('attr', 'data-test-highlighted-range')
.should('not.be.empty')
.and('not.equal', '0')
.then((range) => {
expectExists(range);
callback(parseInt(range, 10));
});
}

View File

@@ -1,11 +1,11 @@
import { waitForHeaderBrandTitle } from './shared/ApplicationLoad';
import { getHeaderBrandTitle } from './support/interactions/header';
describe('application is initialized as expected', () => {
it('loads title as expected', () => {
// act
cy.visit('/');
// assert
waitForHeaderBrandTitle();
getHeaderBrandTitle();
});
it('there are no console.error output', () => {
// act

View File

@@ -1,12 +1,13 @@
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { openCard } from './support/interactions/card';
describe('revert toggle', () => {
context('toggle switch', () => {
beforeEach(() => {
cy.visit('/');
cy.get('.card')
.eq(1) // to get 2nd element, first is often cleanup that may lack revert button
.click(); // open the card card
openCard({
cardIndex: 1, // first is often cleanup that may lack revert button
});
cy.get('.toggle-switch')
.first()
.as('toggleSwitch');

View File

@@ -1,3 +0,0 @@
export function waitForHeaderBrandTitle() {
cy.contains('h1', 'privacy.sexy');
}

View File

@@ -0,0 +1,7 @@
export function openCard(options: {
readonly cardIndex: number;
}) {
cy.get('.card')
.eq(options.cardIndex)
.click();
}

View File

@@ -0,0 +1,3 @@
export function getHeaderBrandTitle() {
cy.contains('h1', 'privacy.sexy');
}

View File

@@ -0,0 +1,144 @@
import { describe, it, expect } from 'vitest';
import { batchedDebounce } from '@/application/Common/Timing/BatchedDebounce';
import { TimerStub } from '@tests/unit/shared/Stubs/TimerStub';
describe('batchedDebounce', () => {
describe('immediate invocation', () => {
it('does not call the the callback immediately on the first call', () => {
// arrange
const { calledBatches, callback } = createObservableCallback();
const callArg = 'first';
const debounceFunc = batchedDebounce(callback, 100, new TimerStub());
// act
debounceFunc(callArg);
// assert
expect(calledBatches).to.have.lengthOf(0);
});
});
describe('debounce timing', () => {
it('executes the callback after the debounce period', () => {
// arrange
const { calledBatches, callback } = createObservableCallback();
const expectedArg = 'first';
const debouncePeriodInMs = 100;
const timer = new TimerStub();
const debounceFunc = batchedDebounce(callback, debouncePeriodInMs, timer);
// act
debounceFunc(expectedArg);
timer.tickNext(debouncePeriodInMs);
// assert
expect(calledBatches).to.have.lengthOf(1);
expect(calledBatches).to.deep.include([expectedArg]);
});
it('prevents callback invocation within the debounce period', () => {
// arrange
const { calledBatches, callback } = createObservableCallback();
const debouncePeriodInMs = 100;
const timer = new TimerStub();
const debounceFunc = batchedDebounce(callback, debouncePeriodInMs, timer);
// act
debounceFunc('first');
timer.tickNext(debouncePeriodInMs / 4);
debounceFunc('second');
timer.tickNext(debouncePeriodInMs / 4);
debounceFunc('third');
timer.tickNext(debouncePeriodInMs / 4);
// assert
expect(calledBatches).to.have.lengthOf(0);
});
it('resets debounce timer on subsequent calls', () => {
// arrange
const timer = new TimerStub();
const { calledBatches, callback } = createObservableCallback();
const debouncePeriodInMs = 100;
const debounceFunc = batchedDebounce(callback, debouncePeriodInMs, timer);
// act
debounceFunc('first');
timer.tickNext(debouncePeriodInMs * 0.9);
debounceFunc('second');
timer.tickNext(debouncePeriodInMs * 0.9);
debounceFunc('third');
timer.tickNext(debouncePeriodInMs * 0.9);
// assert
expect(calledBatches).to.have.lengthOf(0);
});
it('does not call the callback again if no new calls are made after the debounce period', () => {
// arrange
const { calledBatches, callback } = createObservableCallback();
const debouncePeriodInMs = 100;
const timer = new TimerStub();
const debounceFunc = batchedDebounce(callback, debouncePeriodInMs, timer);
// act
debounceFunc('first');
timer.tickNext(debouncePeriodInMs);
timer.tickNext(debouncePeriodInMs);
timer.tickNext(debouncePeriodInMs);
timer.tickNext(debouncePeriodInMs);
// assert
expect(calledBatches).to.have.lengthOf(1);
});
});
describe('batching calls', () => {
it('batches multiple calls within the debounce period', () => {
// arrange
const { calledBatches, callback } = createObservableCallback();
const firstCallArg = 'first';
const secondCallArg = 'second';
const debouncePeriodInMs = 100;
const timer = new TimerStub();
const debounceFunc = batchedDebounce(callback, debouncePeriodInMs, timer);
// act
debounceFunc(firstCallArg);
debounceFunc(secondCallArg);
timer.tickNext(debouncePeriodInMs);
// assert
expect(calledBatches).to.have.lengthOf(1);
expect(calledBatches).to.deep.include([firstCallArg, secondCallArg]);
});
it('handles multiple separate batches correctly', () => {
// arrange
const { calledBatches, callback } = createObservableCallback();
const debouncePeriodInMs = 100;
const firstBatchArg = 'first';
const secondBatchArg = 'second';
const timer = new TimerStub();
const debounceFunc = batchedDebounce(callback, debouncePeriodInMs, timer);
// act
debounceFunc(firstBatchArg);
timer.tickNext(debouncePeriodInMs);
debounceFunc(secondBatchArg);
timer.tickNext(debouncePeriodInMs);
// assert
expect(calledBatches).to.have.lengthOf(2);
expect(calledBatches[0]).to.deep.equal([firstBatchArg]);
expect(calledBatches[1]).to.deep.equal([secondBatchArg]);
});
});
});
function createObservableCallback() {
const calledBatches = new Array<readonly string[]>();
const callback = (batches: readonly string[]): void => {
calledBatches.push(batches);
};
return {
calledBatches,
callback,
};
}

View File

@@ -0,0 +1,78 @@
import {
describe, it, expect, beforeEach,
afterEach,
} from 'vitest';
import { PlatformTimer } from '@/application/Common/Timing/PlatformTimer';
describe('PlatformTimer', () => {
let originalSetTimeout: typeof global.setTimeout;
let originalClearTimeout: typeof global.clearTimeout;
let originalDateNow: typeof global.Date.now;
beforeEach(() => {
originalSetTimeout = global.setTimeout;
originalClearTimeout = global.clearTimeout;
originalDateNow = Date.now;
Date.now = () => originalDateNow();
});
afterEach(() => {
global.setTimeout = originalSetTimeout;
global.clearTimeout = originalClearTimeout;
Date.now = originalDateNow;
});
describe('setTimeout', () => {
it('calls the global setTimeout with the provided delay', () => {
// arrange
const expectedDelay = 55;
let actualDelay: number | undefined;
global.setTimeout = ((_, delay) => {
actualDelay = delay;
}) as typeof global.setTimeout;
// act
PlatformTimer.setTimeout(() => { /* NOOP */ }, expectedDelay);
// assert
expect(actualDelay).to.equal(expectedDelay);
});
it('calls the global setTimeout with the provided callback', () => {
// arrange
const expectedCallback = () => { /* NOOP */ };
let actualCallback: typeof expectedCallback | undefined;
global.setTimeout = ((callback) => {
actualCallback = callback;
}) as typeof global.setTimeout;
// act
PlatformTimer.setTimeout(expectedCallback, 33);
// assert
expect(actualCallback).to.equal(expectedCallback);
});
});
describe('clearTimeout', () => {
it('should clear timeout', () => {
// arrange
let actualTimer: ReturnType<typeof PlatformTimer.setTimeout> | undefined;
global.clearTimeout = ((timer) => {
actualTimer = timer;
}) as typeof global.clearTimeout;
const expectedTimer = PlatformTimer.setTimeout(() => { /* NOOP */ }, 1);
// act
PlatformTimer.clearTimeout(expectedTimer);
// assert
expect(actualTimer).to.equal(expectedTimer);
});
});
describe('dateNow', () => {
it('should get current date', () => {
// arrange
const expected = Date.now();
Date.now = () => expected;
// act
const actual = PlatformTimer.dateNow();
// assert
expect(expected).to.equal(actual);
});
});
});

View File

@@ -1,8 +1,6 @@
import { describe, it, expect } from 'vitest';
import { throttle, ITimer, Timeout } from '@/presentation/components/Shared/Throttle';
import { EventSource } from '@/infrastructure/Events/EventSource';
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
import { createMockTimeout } from '@tests/unit/shared/Stubs/TimeoutStub';
import { TimerStub } from '@tests/unit/shared/Stubs/TimerStub';
import { throttle } from '@/application/Common/Timing/Throttle';
describe('throttle', () => {
describe('validates parameters', () => {
@@ -34,7 +32,7 @@ describe('throttle', () => {
});
it('should call the callback immediately', () => {
// arrange
const timer = new TimerMock();
const timer = new TimerStub();
let totalRuns = 0;
const callback = () => totalRuns++;
const throttleFunc = throttle(callback, 500, timer);
@@ -45,7 +43,7 @@ describe('throttle', () => {
});
it('should call the callback again after the timeout', () => {
// arrange
const timer = new TimerMock();
const timer = new TimerStub();
let totalRuns = 0;
const callback = () => totalRuns++;
const waitInMs = 500;
@@ -60,7 +58,7 @@ describe('throttle', () => {
});
it('should call the callback at most once at given time', () => {
// arrange
const timer = new TimerMock();
const timer = new TimerStub();
let totalRuns = 0;
const callback = () => totalRuns++;
const waitInMs = 500;
@@ -77,7 +75,7 @@ describe('throttle', () => {
});
it('should call the callback as long as delay is waited', () => {
// arrange
const timer = new TimerMock();
const timer = new TimerStub();
let totalRuns = 0;
const callback = () => totalRuns++;
const waitInMs = 500;
@@ -93,7 +91,7 @@ describe('throttle', () => {
});
it('should call arguments as expected', () => {
// arrange
const timer = new TimerMock();
const timer = new TimerStub();
const expected = [1, 2, 3];
const actual = new Array<number>();
const callback = (arg: number) => { actual.push(arg); };
@@ -108,41 +106,3 @@ describe('throttle', () => {
expect(expected).to.deep.equal(actual);
});
});
class TimerMock implements ITimer {
private timeChanged = new EventSource<number>();
private subscriptions = new Array<IEventSubscription>();
private currentTime = 0;
public setTimeout(callback: () => void, ms: number): Timeout {
const runTime = this.currentTime + ms;
const subscription = this.timeChanged.on((time) => {
if (time >= runTime) {
callback();
subscription.unsubscribe();
}
});
this.subscriptions.push(subscription);
const id = this.subscriptions.length - 1;
return createMockTimeout(id);
}
public clearTimeout(timeoutId: Timeout): void {
this.subscriptions[+timeoutId].unsubscribe();
}
public dateNow(): number {
return this.currentTime;
}
public tickNext(ms: number): void {
this.setCurrentTime(this.currentTime + ms);
}
public setCurrentTime(ms: number): void {
this.currentTime = ms;
this.timeChanged.notify(this.currentTime);
}
}

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

View File

@@ -1,7 +1,10 @@
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { RecommendationLevel } from '@/domain/RecommendationLevel';
import { CategoryCollectionStateStub } from '@tests/unit/shared/Stubs/CategoryCollectionStateStub';
import { ScriptSelectionStub } from '@tests/unit/shared/Stubs/ScriptSelectionStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub';
export class SelectionStateTestScenario {
public readonly all: readonly SelectedScript[];
@@ -28,13 +31,21 @@ export class SelectionStateTestScenario {
this.all = [...this.allStandard, ...this.allStrict, ...this.allUnrecommended];
}
public generateState(selectedScripts: readonly SelectedScript[]) {
public generateState(selectedScripts: readonly SelectedScript[] = []) {
const allScripts = this.all.map((s) => s.script);
return new CategoryCollectionStateStub(allScripts)
const scriptSelection = new ScriptSelectionStub()
.withSelectedScripts(selectedScripts);
const categoryCollectionState = new CategoryCollectionStateStub(allScripts)
.withSelection(new UserSelectionStub().withScripts(scriptSelection));
return {
scriptsStub: scriptSelection,
stateStub: categoryCollectionState,
};
}
}
function createSelectedScripts(level?: RecommendationLevel, ...ids: string[]) {
return ids.map((id) => new SelectedScript(new ScriptStub(id).withLevel(level), false));
function createSelectedScripts(level?: RecommendationLevel, ...ids: string[]): SelectedScript[] {
return ids.map((id) => new SelectedScriptStub(
new ScriptStub(id).withLevel(level),
).withRevert(false));
}

View File

@@ -3,10 +3,14 @@ import {
SelectionCheckContext, SelectionMutationContext, SelectionType,
getCurrentSelectionType, setCurrentSelectionType,
} from '@/presentation/components/Scripts/Menu/Selector/SelectionTypeHandler';
import { scrambledEqual } from '@/application/Common/Array';
import { RecommendationLevel } from '@/domain/RecommendationLevel';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { EnumRangeTestRunner } from '@tests/unit/application/Common/EnumRangeTestRunner';
import { ScriptSelection } from '@/application/Context/State/Selection/Script/ScriptSelection';
import { MethodCall } from '@tests/unit/shared/Stubs/StubWithObservableMethodCalls';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { scrambledEqual } from '@/application/Common/Array';
import { IScript } from '@/domain/IScript';
import { SelectionStateTestScenario } from './SelectionStateTestScenario';
describe('SelectionTypeHandler', () => {
@@ -14,11 +18,11 @@ describe('SelectionTypeHandler', () => {
describe('throws with invalid type', () => {
// arrange
const scenario = new SelectionStateTestScenario();
const state = scenario.generateState([]);
const { stateStub } = scenario.generateState([]);
// act
const act = (type: SelectionType) => setCurrentSelectionType(
type,
createMutationContext(state),
createMutationContext(stateStub),
);
// assert
new EnumRangeTestRunner(act)
@@ -28,44 +32,64 @@ describe('SelectionTypeHandler', () => {
describe('select types as expected', () => {
// arrange
const scenario = new SelectionStateTestScenario();
const initialScriptsCases = [{
name: 'when nothing is selected',
initialScripts: [],
}, {
name: 'when some scripts are selected',
initialScripts: [...scenario.allStandard, ...scenario.someStrict],
}, {
name: 'when all scripts are selected',
initialScripts: scenario.all,
}];
for (const initialScriptsCase of initialScriptsCases) {
describe(initialScriptsCase.name, () => {
const state = scenario.generateState(initialScriptsCase.initialScripts);
const typeExpectations = [{
input: SelectionType.None,
output: [],
}, {
input: SelectionType.Standard,
output: scenario.allStandard,
}, {
input: SelectionType.Strict,
output: [...scenario.allStandard, ...scenario.allStrict],
}, {
input: SelectionType.All,
output: scenario.all,
}];
for (const expectation of typeExpectations) {
// act
it(`${SelectionType[expectation.input]} returns as expected`, () => {
setCurrentSelectionType(expectation.input, createMutationContext(state));
// assert
const actual = state.selection.selectedScripts;
const expected = expectation.output;
expect(scrambledEqual(actual, expected));
});
const testScenarios: ReadonlyArray<{
readonly givenType: SelectionType;
readonly expectedCall: MethodCall<ScriptSelection>;
}> = [
{
givenType: SelectionType.None,
expectedCall: {
methodName: 'deselectAll',
args: [],
},
},
{
givenType: SelectionType.Standard,
expectedCall: {
methodName: 'selectOnly',
args: [
scenario.allStandard.map((s) => s.script),
],
},
},
{
givenType: SelectionType.Strict,
expectedCall: {
methodName: 'selectOnly',
args: [[
...scenario.allStandard.map((s) => s.script),
...scenario.allStrict.map((s) => s.script),
]],
},
},
{
givenType: SelectionType.All,
expectedCall: {
methodName: 'selectAll',
args: [],
},
},
];
testScenarios.forEach(({
givenType, expectedCall,
}) => {
it(`${SelectionType[givenType]} modifies as expected`, () => {
const { stateStub, scriptsStub } = scenario.generateState();
// act
setCurrentSelectionType(givenType, createMutationContext(stateStub));
// assert
const call = scriptsStub.callHistory.find(
(c) => c.methodName === expectedCall.methodName,
);
expectExists(call);
if (expectedCall.args.length > 0) { /** {@link ScriptSelection.selectOnly}. */
expect(scrambledEqual(
call.args[0] as IScript[],
expectedCall.args[0] as IScript[],
)).to.equal(true);
}
});
}
});
});
});
describe('getCurrentSelectionType', () => {
@@ -106,9 +130,9 @@ describe('SelectionTypeHandler', () => {
}];
for (const testCase of testCases) {
it(testCase.name, () => {
const state = scenario.generateState(testCase.selection);
const { stateStub } = scenario.generateState(testCase.selection);
// act
const actual = getCurrentSelectionType(createCheckContext(state));
const actual = getCurrentSelectionType(createCheckContext(stateStub));
// assert
expect(actual).to.deep.equal(
testCase.expected,
@@ -130,14 +154,14 @@ describe('SelectionTypeHandler', () => {
function createMutationContext(state: ICategoryCollectionState): SelectionMutationContext {
return {
selection: state.selection,
selection: state.selection.scripts,
collection: state.collection,
};
}
function createCheckContext(state: ICategoryCollectionState): SelectionCheckContext {
return {
selection: state.selection,
selection: state.selection.scripts,
collection: state.collection,
};
}

View File

@@ -1,106 +1,101 @@
import { describe, it, expect } from 'vitest';
import { CategoryReverter } from '@/presentation/components/Scripts/View/Tree/NodeContent/Reverter/CategoryReverter';
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 { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { getCategoryNodeId } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter';
import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub';
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { CategorySelectionStub } from '@tests/unit/shared/Stubs/CategorySelectionStub';
describe('CategoryReverter', () => {
describe('getState', () => {
// arrange
const scripts = [
new ScriptStub('revertable').withRevertCode('REM revert me'),
new ScriptStub('revertable2').withRevertCode('REM revert me 2'),
new ScriptStub('reversible').withRevertCode('REM revert me'),
new ScriptStub('reversible2').withRevertCode('REM revert me 2'),
];
const category = new CategoryStub(1).withScripts(...scripts);
const nodeId = getCategoryNodeId(category);
const collection = new CategoryCollectionStub().withAction(category);
const sut = new CategoryReverter(nodeId, collection);
const testCases = [
const testScenarios: ReadonlyArray<{
readonly description: string;
readonly selectedScripts: readonly SelectedScript[];
readonly expectedState: boolean;
}> = [
{
name: 'false when subscripts are not reverted',
state: scripts.map((script) => new SelectedScript(script, false)),
expected: false,
description: 'returns `false` for non-reverted subscripts',
selectedScripts: scripts.map(
(script) => new SelectedScriptStub(script).withRevert(false),
),
expectedState: false,
},
{
name: 'false when some subscripts are reverted',
state: [new SelectedScript(scripts[0], false), new SelectedScript(scripts[0], true)],
expected: false,
description: 'returns `false` when only some subscripts are reverted',
selectedScripts: [
new SelectedScriptStub(scripts[0]).withRevert(false),
new SelectedScriptStub(scripts[0]).withRevert(true),
],
expectedState: false,
},
{
name: 'false when subscripts are not reverted',
state: scripts.map((script) => new SelectedScript(script, true)),
expected: true,
description: 'returns `true` when all subscripts are reverted',
selectedScripts: scripts.map(
(script) => new SelectedScriptStub(script).withRevert(true),
),
expectedState: true,
},
];
for (const testCase of testCases) {
it(testCase.name, () => {
testScenarios.forEach((
{ description, selectedScripts, expectedState },
) => {
it(description, () => {
// act
const actual = sut.getState(testCase.state);
const actual = sut.getState(selectedScripts);
// assert
expect(actual).to.equal(testCase.expected);
expect(actual).to.equal(expectedState);
});
}
});
});
describe('selectWithRevertState', () => {
// arrange
const scripts = [
new ScriptStub('revertable').withRevertCode('REM revert me'),
new ScriptStub('revertable2').withRevertCode('REM revert me 2'),
const allScripts = [
new ScriptStub('reversible').withRevertCode('REM revert me'),
new ScriptStub('reversible2').withRevertCode('REM revert me 2'),
];
const category = new CategoryStub(1).withScripts(...scripts);
const category = new CategoryStub(1).withScripts(...allScripts);
const collection = new CategoryCollectionStub().withAction(category);
/* eslint-disable object-property-newline */
const testCases = [
const testScenarios: ReadonlyArray<{
readonly description: string;
readonly expectedRevert: boolean;
}> = [
{
name: 'selects with revert state when not selected',
selection: [],
revert: true, expectRevert: true,
description: 'selects with revert',
expectedRevert: true,
},
{
name: 'selects with non-revert state when not selected',
selection: [],
revert: false, expectRevert: false,
},
{
name: 'switches when already selected with revert state',
selection: scripts.map((script) => new SelectedScript(script, true)),
revert: false, expectRevert: false,
},
{
name: 'switches when already selected with not revert state',
selection: scripts.map((script) => new SelectedScript(script, false)),
revert: true, expectRevert: true,
},
{
name: 'keeps revert state when already selected with revert state',
selection: scripts.map((script) => new SelectedScript(script, true)),
revert: true, expectRevert: true,
},
{
name: 'keeps revert state deselected when already selected wtih non revert state',
selection: scripts.map((script) => new SelectedScript(script, false)),
revert: false, expectRevert: false,
description: 'selects without revert',
expectedRevert: false,
},
];
/* eslint-enable object-property-newline */
const nodeId = getCategoryNodeId(category);
for (const testCase of testCases) {
it(testCase.name, () => {
const selection = new UserSelection(collection, testCase.selection);
testScenarios.forEach((
{ description, expectedRevert },
) => {
it(description, () => {
const categorySelection = new CategorySelectionStub();
const sut = new CategoryReverter(nodeId, collection);
const revertState = expectedRevert;
// act
sut.selectWithRevertState(testCase.revert, selection);
sut.selectWithRevertState(
revertState,
new UserSelectionStub().withCategories(categorySelection),
);
// assert
expect(sut.getState(selection.selectedScripts)).to.equal(testCase.expectRevert);
expect(selection.selectedScripts).has.lengthOf(2);
expect(selection.selectedScripts[0].id).equal(scripts[0].id);
expect(selection.selectedScripts[1].id).equal(scripts[1].id);
expect(selection.selectedScripts[0].revert).equal(testCase.expectRevert);
expect(selection.selectedScripts[1].revert).equal(testCase.expectRevert);
expect(categorySelection.isCategorySelected(category.id, expectedRevert)).to.equal(true);
});
}
});
});
});

View File

@@ -1,90 +1,118 @@
import { describe, it, expect } from 'vitest';
import { ScriptReverter } from '@/presentation/components/Scripts/View/Tree/NodeContent/Reverter/ScriptReverter';
import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
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 { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
import { getScriptNodeId } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter';
import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub';
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { ScriptSelectionStub } from '@tests/unit/shared/Stubs/ScriptSelectionStub';
describe('ScriptReverter', () => {
describe('getState', () => {
it('false when script is not selected', () => {
// arrange
const script = new ScriptStub('id');
const nodeId = getScriptNodeId(script);
const sut = new ScriptReverter(nodeId);
// act
const actual = sut.getState([]);
// assert
expect(actual).to.equal(false);
});
it('false when script is selected but not reverted', () => {
// arrange
const scripts = [new SelectedScriptStub('id'), new SelectedScriptStub('dummy')];
const nodeId = getScriptNodeId(scripts[0].script);
const sut = new ScriptReverter(nodeId);
// act
const actual = sut.getState(scripts);
// assert
expect(actual).to.equal(false);
});
it('true when script is selected and reverted', () => {
// arrange
const scripts = [new SelectedScriptStub('id', true), new SelectedScriptStub('dummy')];
const nodeId = getScriptNodeId(scripts[0].script);
const sut = new ScriptReverter(nodeId);
// act
const actual = sut.getState(scripts);
// assert
expect(actual).to.equal(true);
// arrange
const script = new ScriptStub('id');
const nodeId = getScriptNodeId(script);
const testScenarios: ReadonlyArray<{
readonly description: string;
readonly selectedScripts: readonly SelectedScript[];
readonly expectedState: boolean;
}> = [
{
description: 'returns `false` when script is not selected',
selectedScripts: [],
expectedState: false,
},
{
description: 'returns `false` when script is selected but not reverted',
selectedScripts: [
new SelectedScriptStub(script).withRevert(false),
new SelectedScriptStub(new ScriptStub('dummy')),
],
expectedState: false,
},
{
description: 'returns `true` when script is selected and reverted',
selectedScripts: [
new SelectedScriptStub(script).withRevert(true),
new SelectedScriptStub(new ScriptStub('dummy')),
],
expectedState: true,
},
];
testScenarios.forEach((
{ description, selectedScripts, expectedState },
) => {
it(description, () => {
const sut = new ScriptReverter(nodeId);
// act
const actual = sut.getState(selectedScripts);
// assert
expect(actual).to.equal(expectedState);
});
});
});
describe('selectWithRevertState', () => {
// arrange
const script = new ScriptStub('id');
const collection = new CategoryCollectionStub()
.withAction(new CategoryStub(5).withScript(script));
/* eslint-disable object-property-newline */
const testCases = [
const testScenarios: ReadonlyArray<{
readonly description: string;
readonly selection: readonly SelectedScript[];
readonly expectedRevert: boolean;
}> = [
{
name: 'selects with revert state when not selected',
selection: [], revert: true, expectRevert: true,
description: 'selects as reverted when initially unselected',
selection: [],
expectedRevert: true,
},
{
name: 'selects with non-revert state when not selected',
selection: [], revert: false, expectRevert: false,
description: 'selects as non-reverted when initially unselected',
selection: [],
expectedRevert: false,
},
{
name: 'switches when already selected with revert state',
selection: [new SelectedScript(script, true)], revert: false, expectRevert: false,
description: 'toggles to non-reverted for previously reverted scripts',
selection: [
new SelectedScriptStub(script).withRevert(true),
],
expectedRevert: false,
},
{
name: 'switches when already selected with not revert state',
selection: [new SelectedScript(script, false)], revert: true, expectRevert: true,
description: 'toggles to reverted for previously non-reverted scripts',
selection: [
new SelectedScriptStub(script).withRevert(false),
],
expectedRevert: true,
},
{
name: 'keeps revert state when already selected with revert state',
selection: [new SelectedScript(script, true)], revert: true, expectRevert: true,
description: 'maintains reverted state for already reverted scripts',
selection: [
new SelectedScriptStub(script).withRevert(true),
],
expectedRevert: true,
},
{
name: 'keeps revert state deselected when already selected with non revert state',
selection: [new SelectedScript(script, false)], revert: false, expectRevert: false,
description: 'maintains non-reverted state for already non-reverted scripts',
selection: [
new SelectedScriptStub(script).withRevert(false),
],
expectedRevert: false,
},
];
/* eslint-enable object-property-newline */
const nodeId = getScriptNodeId(script);
for (const testCase of testCases) {
it(testCase.name, () => {
const selection = new UserSelection(collection, testCase.selection);
testScenarios.forEach((
{ description, selection, expectedRevert },
) => {
it(description, () => {
const scriptSelection = new ScriptSelectionStub()
.withSelectedScripts(selection);
const userSelection = new UserSelectionStub().withScripts(scriptSelection);
const sut = new ScriptReverter(nodeId);
const revertState = expectedRevert;
// act
sut.selectWithRevertState(testCase.revert, selection);
sut.selectWithRevertState(revertState, userSelection);
// assert
expect(selection.isSelected(script.id)).to.equal(true);
expect(selection.selectedScripts[0].revert).equal(testCase.expectRevert);
expect(scriptSelection.isScriptSelected(script.id, expectedRevert)).to.equal(true);
});
}
});
});
});

View File

@@ -7,6 +7,7 @@ import { TreeNodeCheckState } from '@/presentation/components/Scripts/View/Tree/
import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub';
import { TreeNodeStateChangedEmittedEventStub } from '@tests/unit/shared/Stubs/TreeNodeStateChangedEmittedEventStub';
import { UseUserSelectionStateStub } from '@tests/unit/shared/Stubs/UseUserSelectionStateStub';
import { ScriptSelectionStub } from '@tests/unit/shared/Stubs/ScriptSelectionStub';
describe('useCollectionSelectionStateUpdater', () => {
describe('updateNodeSelection', () => {
@@ -56,9 +57,12 @@ describe('useCollectionSelectionStateUpdater', () => {
it('adds to selection if not already selected', () => {
// arrange
const { returnObject, useSelectionStateStub } = runHook();
const selectionStub = new UserSelectionStub([]);
selectionStub.isSelected = () => false;
useSelectionStateStub.withUserSelection(selectionStub);
const isScriptInitiallySelected = false;
const scriptSelectionStub = new ScriptSelectionStub()
.withIsSelectedResult(isScriptInitiallySelected);
useSelectionStateStub.withUserSelection(
new UserSelectionStub().withScripts(scriptSelectionStub),
);
const node = createTreeNodeStub({
isBranch: false,
currentState: TreeNodeCheckState.Checked,
@@ -73,14 +77,17 @@ describe('useCollectionSelectionStateUpdater', () => {
returnObject.updateNodeSelection(mockEvent);
// assert
expect(useSelectionStateStub.isSelectionModified()).to.equal(true);
expect(selectionStub.isScriptAdded(node.id)).to.equal(true);
expect(scriptSelectionStub.isScriptSelected(node.id, false)).to.equal(true);
});
it('does nothing if already selected', () => {
// arrange
const { returnObject, useSelectionStateStub } = runHook();
const selectionStub = new UserSelectionStub([]);
selectionStub.isSelected = () => true;
useSelectionStateStub.withUserSelection(selectionStub);
const isScriptInitiallySelected = true;
const scriptSelectionStub = new ScriptSelectionStub()
.withIsSelectedResult(isScriptInitiallySelected);
useSelectionStateStub.withUserSelection(
new UserSelectionStub().withScripts(scriptSelectionStub),
);
const mockEvent = new TreeNodeStateChangedEmittedEventStub()
.withNode(
createTreeNodeStub({
@@ -102,9 +109,12 @@ describe('useCollectionSelectionStateUpdater', () => {
it('removes from selection if already selected', () => {
// arrange
const { returnObject, useSelectionStateStub } = runHook();
const selectionStub = new UserSelectionStub([]);
selectionStub.isSelected = () => true;
useSelectionStateStub.withUserSelection(selectionStub);
const isScriptInitiallySelected = true;
const scriptSelectionStub = new ScriptSelectionStub()
.withIsSelectedResult(isScriptInitiallySelected);
useSelectionStateStub.withUserSelection(
new UserSelectionStub().withScripts(scriptSelectionStub),
);
const node = createTreeNodeStub({
isBranch: false,
currentState: TreeNodeCheckState.Unchecked,
@@ -119,14 +129,17 @@ describe('useCollectionSelectionStateUpdater', () => {
returnObject.updateNodeSelection(mockEvent);
// assert
expect(useSelectionStateStub.isSelectionModified()).to.equal(true);
expect(selectionStub.isScriptRemoved(node.id)).to.equal(true);
expect(scriptSelectionStub.isScriptDeselected(node.id)).to.equal(true);
});
it('does nothing if not already selected', () => {
// arrange
const { returnObject, useSelectionStateStub } = runHook();
const selectionStub = new UserSelectionStub([]);
selectionStub.isSelected = () => false;
useSelectionStateStub.withUserSelection(selectionStub);
const isScriptInitiallySelected = false;
const scriptSelectionStub = new ScriptSelectionStub()
.withIsSelectedResult(isScriptInitiallySelected);
useSelectionStateStub.withUserSelection(
new UserSelectionStub().withScripts(scriptSelectionStub),
);
const mockEvent = new TreeNodeStateChangedEmittedEventStub()
.withNode(
createTreeNodeStub({

View File

@@ -4,6 +4,7 @@ import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub'
import { getScriptNodeId } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter';
import { IScript } from '@/domain/IScript';
import { UseUserSelectionStateStub } from '@tests/unit/shared/Stubs/UseUserSelectionStateStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
describe('useSelectedScriptNodeIds', () => {
it('returns an empty array when no scripts are selected', () => {
@@ -19,8 +20,8 @@ describe('useSelectedScriptNodeIds', () => {
it('immediately', () => {
// arrange
const selectedScripts = [
new SelectedScriptStub('id-1'),
new SelectedScriptStub('id-2'),
new SelectedScriptStub(new ScriptStub('id-1')),
new SelectedScriptStub(new ScriptStub('id-2')),
];
const parsedNodeIds = new Map<IScript, string>([
[selectedScripts[0].script, 'expected-id-1'],
@@ -43,8 +44,8 @@ describe('useSelectedScriptNodeIds', () => {
// arrange
const initialScripts = [];
const changedScripts = [
new SelectedScriptStub('id-1'),
new SelectedScriptStub('id-2'),
new SelectedScriptStub(new ScriptStub('id-1')),
new SelectedScriptStub(new ScriptStub('id-2')),
];
const parsedNodeIds = new Map<IScript, string>([
[changedScripts[0].script, 'expected-id-1'],

View File

@@ -1,19 +1,20 @@
import { describe, it, expect } from 'vitest';
import { nextTick, watch } from 'vue';
import { SelectionModifier, useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
import { UseCollectionStateStub } from '@tests/unit/shared/Stubs/UseCollectionStateStub';
import { UseAutoUnsubscribedEventsStub } from '@tests/unit/shared/Stubs/UseAutoUnsubscribedEventsStub';
import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { CategoryCollectionStateStub } from '@tests/unit/shared/Stubs/CategoryCollectionStateStub';
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
import { ScriptSelectionStub } from '@tests/unit/shared/Stubs/ScriptSelectionStub';
describe('useUserSelectionState', () => {
describe('currentSelection', () => {
it('initializes with correct selection', () => {
// arrange
const expectedSelection = new UserSelectionStub([new ScriptStub('initialSelectedScript')]);
const expectedSelection = new UserSelectionStub();
const collectionStateStub = new UseCollectionStateStub()
.withState(new CategoryCollectionStateStub().withSelection(expectedSelection));
// act
@@ -27,8 +28,8 @@ describe('useUserSelectionState', () => {
describe('once collection state is changed', () => {
it('updated', () => {
// arrange
const initialSelection = new UserSelectionStub([new ScriptStub('initialSelectedScript')]);
const changedSelection = new UserSelectionStub([new ScriptStub('changedSelectedScript')]);
const initialSelection = new UserSelectionStub();
const changedSelection = new UserSelectionStub();
const collectionStateStub = new UseCollectionStateStub()
.withState(new CategoryCollectionStateStub().withSelection(initialSelection));
const { returnObject } = runHook({
@@ -45,8 +46,9 @@ describe('useUserSelectionState', () => {
});
it('not updated when old state changes', async () => {
// arrange
const oldSelectionState = new UserSelectionStub([new ScriptStub('inOldState')]);
const newSelectionState = new UserSelectionStub([new ScriptStub('inNewState')]);
const oldScriptSelection = new ScriptSelectionStub();
const oldSelectionState = new UserSelectionStub().withScripts(oldScriptSelection);
const newSelectionState = new UserSelectionStub();
const collectionStateStub = new UseCollectionStateStub()
.withState(new CategoryCollectionStateStub().withSelection(oldSelectionState));
const { returnObject } = runHook({
@@ -61,7 +63,9 @@ describe('useUserSelectionState', () => {
totalUpdates++;
});
// act
oldSelectionState.triggerSelectionChangedEvent([new SelectedScriptStub('newInOldState')]);
oldScriptSelection.triggerSelectionChangedEvent([
new SelectedScriptStub(new ScriptStub('newInOldState')),
]);
await nextTick();
// assert
expect(totalUpdates).to.equal(0);
@@ -69,8 +73,8 @@ describe('useUserSelectionState', () => {
describe('triggers change', () => {
it('with new selection reference', async () => {
// arrange
const oldSelection = new UserSelectionStub([]);
const newSelection = new UserSelectionStub([]);
const oldSelection = new UserSelectionStub();
const newSelection = new UserSelectionStub();
const initialCollectionState = new CategoryCollectionStateStub()
.withSelection(oldSelection);
const changedCollectionState = new CategoryCollectionStateStub()
@@ -93,7 +97,7 @@ describe('useUserSelectionState', () => {
});
it('with the same selection reference', async () => {
// arrange
const userSelection = new UserSelectionStub([new ScriptStub('sameScriptInSameReference')]);
const userSelection = new UserSelectionStub();
const initialCollectionState = new CategoryCollectionStateStub()
.withSelection(userSelection);
const changedCollectionState = new CategoryCollectionStateStub()
@@ -119,26 +123,38 @@ describe('useUserSelectionState', () => {
describe('once selection state is changed', () => {
it('updated with same collection state', async () => {
// arrange
const initialScripts = [new ScriptStub('initialSelectedScript')];
const changedScripts = [new SelectedScriptStub('changedSelectedScript')];
const selectionState = new UserSelectionStub(initialScripts);
const collectionState = new CategoryCollectionStateStub().withSelection(selectionState);
const initialScripts = [
new SelectedScriptStub(new ScriptStub('initialSelectedScript')),
];
const changedScripts = [
new SelectedScriptStub(new ScriptStub('changedSelectedScript')),
];
const scriptSelectionStub = new ScriptSelectionStub()
.withSelectedScripts(initialScripts);
const expectedSelectionState = new UserSelectionStub().withScripts(scriptSelectionStub);
const collectionState = new CategoryCollectionStateStub()
.withSelection(expectedSelectionState);
const collectionStateStub = new UseCollectionStateStub().withState(collectionState);
const { returnObject } = runHook({
useCollectionState: collectionStateStub,
});
// act
selectionState.triggerSelectionChangedEvent(changedScripts);
scriptSelectionStub.triggerSelectionChangedEvent(changedScripts);
await nextTick();
// assert
const actualSelection = returnObject.currentSelection.value;
expect(actualSelection).to.equal(selectionState);
expect(actualSelection).to.equal(expectedSelectionState);
});
it('updated once collection state is changed', async () => {
// arrange
const changedScripts = [new SelectedScriptStub('changedSelectedScript')];
const newSelectionState = new UserSelectionStub([new ScriptStub('initialSelectedScriptInNewCollection')]);
const initialCollectionState = new CategoryCollectionStateStub().withSelectedScripts([new SelectedScriptStub('initialSelectedScriptInInitialCollection')]);
const changedScripts = [
new SelectedScriptStub(new ScriptStub('changedSelectedScript')),
];
const scriptSelectionStub = new ScriptSelectionStub();
const newSelectionState = new UserSelectionStub().withScripts(scriptSelectionStub);
const initialCollectionState = new CategoryCollectionStateStub().withSelectedScripts([
new SelectedScriptStub(new ScriptStub('initialSelectedScriptInInitialCollection')),
]);
const collectionStateStub = new UseCollectionStateStub().withState(initialCollectionState);
const { returnObject } = runHook({ useCollectionState: collectionStateStub });
// act
@@ -146,7 +162,7 @@ describe('useUserSelectionState', () => {
newState: new CategoryCollectionStateStub().withSelection(newSelectionState),
immediateOnly: false,
});
newSelectionState.triggerSelectionChangedEvent(changedScripts);
scriptSelectionStub.triggerSelectionChangedEvent(changedScripts);
// assert
const actualSelection = returnObject.currentSelection.value;
expect(actualSelection).to.equal(newSelectionState);
@@ -156,17 +172,19 @@ describe('useUserSelectionState', () => {
// arrange
const oldSelectedScriptsArrayReference = [];
const newSelectedScriptsArrayReference = [];
const userSelection = new UserSelectionStub(oldSelectedScriptsArrayReference)
const scriptSelectionStub = new ScriptSelectionStub()
.withSelectedScripts(oldSelectedScriptsArrayReference);
const collectionStateStub = new UseCollectionStateStub()
.withState(new CategoryCollectionStateStub().withSelection(userSelection));
.withState(new CategoryCollectionStateStub().withSelection(
new UserSelectionStub().withScripts(scriptSelectionStub),
));
const { returnObject } = runHook({ useCollectionState: collectionStateStub });
let isChangeTriggered = false;
watch(returnObject.currentSelection, () => {
isChangeTriggered = true;
});
// act
userSelection.triggerSelectionChangedEvent(newSelectedScriptsArrayReference);
scriptSelectionStub.triggerSelectionChangedEvent(newSelectedScriptsArrayReference);
await nextTick();
// assert
expect(isChangeTriggered).to.equal(true);
@@ -174,17 +192,19 @@ describe('useUserSelectionState', () => {
it('with same selected scripts array reference', async () => {
// arrange
const sharedSelectedScriptsReference = [];
const userSelection = new UserSelectionStub([])
const scriptSelectionStub = new ScriptSelectionStub()
.withSelectedScripts(sharedSelectedScriptsReference);
const collectionStateStub = new UseCollectionStateStub()
.withState(new CategoryCollectionStateStub().withSelection(userSelection));
.withState(new CategoryCollectionStateStub().withSelection(
new UserSelectionStub().withScripts(scriptSelectionStub),
));
const { returnObject } = runHook({ useCollectionState: collectionStateStub });
let isChangeTriggered = false;
watch(returnObject.currentSelection, () => {
isChangeTriggered = true;
});
// act
userSelection.triggerSelectionChangedEvent(sharedSelectedScriptsReference);
scriptSelectionStub.triggerSelectionChangedEvent(sharedSelectedScriptsReference);
await nextTick();
// assert
expect(isChangeTriggered).to.equal(true);
@@ -197,7 +217,7 @@ describe('useUserSelectionState', () => {
// arrange
const { returnObject, collectionStateStub } = runHook();
const expectedSelection = collectionStateStub.state.selection;
let mutatedSelection: IUserSelection | undefined;
let mutatedSelection: UserSelection | undefined;
const mutator: SelectionModifier = (selection) => {
mutatedSelection = selection;
};
@@ -210,10 +230,10 @@ describe('useUserSelectionState', () => {
it('new state is modified once collection state is changed', async () => {
// arrange
const { returnObject, collectionStateStub } = runHook();
const expectedSelection = new UserSelectionStub([]);
const expectedSelection = new UserSelectionStub();
const newCollectionState = new CategoryCollectionStateStub()
.withSelection(expectedSelection);
let mutatedSelection: IUserSelection | undefined;
let mutatedSelection: UserSelection | undefined;
const mutator: SelectionModifier = (selection) => {
mutatedSelection = selection;
};
@@ -231,12 +251,12 @@ describe('useUserSelectionState', () => {
it('old state is not modified once collection state is changed', async () => {
// arrange
const oldState = new CategoryCollectionStateStub().withSelectedScripts([
new SelectedScriptStub('scriptFromOldState'),
new SelectedScriptStub(new ScriptStub('scriptFromOldState')),
]);
const collectionStateStub = new UseCollectionStateStub()
.withState(oldState);
const { returnObject } = runHook({ useCollectionState: collectionStateStub });
const expectedSelection = new UserSelectionStub([]);
const expectedSelection = new UserSelectionStub();
const newCollectionState = new CategoryCollectionStateStub()
.withSelection(expectedSelection);
let totalMutations = 0;

View File

@@ -0,0 +1,33 @@
import { batchedDebounce } from '@/application/Common/Timing/BatchedDebounce';
export class BatchedDebounceStub<T> {
public readonly callHistory = new Array<Parameters<typeof batchedDebounce>>();
public readonly collectedArgs = new Array<T>();
private executeImmediately = false;
public func = (
callback: (batches: readonly T[]) => void,
waitInMs: number,
): (arg: T) => void => {
this.callHistory.push([callback, waitInMs]);
return (arg: T) => {
this.collectedArgs.push(arg);
if (this.executeImmediately) {
callback([arg]);
}
};
};
public withImmediateDebouncing(executeImmediately: boolean): this {
this.executeImmediately = executeImmediately;
return this;
}
public execute() {
this.callHistory
.map((call) => call[0])
.forEach((callback) => callback(this.collectedArgs));
}
}

View File

@@ -2,16 +2,17 @@ import { IApplicationCode } from '@/application/Context/State/Code/IApplicationC
import { IUserFilter } from '@/application/Context/State/Filter/IUserFilter';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IScript } from '@/domain/IScript';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { CategoryCollectionStub } from './CategoryCollectionStub';
import { UserSelectionStub } from './UserSelectionStub';
import { UserFilterStub } from './UserFilterStub';
import { ApplicationCodeStub } from './ApplicationCodeStub';
import { CategoryStub } from './CategoryStub';
import { ScriptSelectionStub } from './ScriptSelectionStub';
export class CategoryCollectionStateStub implements ICategoryCollectionState {
public code: IApplicationCode = new ApplicationCodeStub();
@@ -24,10 +25,11 @@ export class CategoryCollectionStateStub implements ICategoryCollectionState {
public collection: ICategoryCollection = new CategoryCollectionStub().withSomeActions();
public selection: IUserSelection = new UserSelectionStub([]);
public selection: UserSelection = new UserSelectionStub();
constructor(readonly allScripts: IScript[] = [new ScriptStub('script-id')]) {
this.selection = new UserSelectionStub(allScripts);
this.selection = new UserSelectionStub()
.withScripts(new ScriptSelectionStub());
this.collection = new CategoryCollectionStub()
.withOs(this.os)
.withTotalScripts(this.allScripts.length)
@@ -60,11 +62,14 @@ export class CategoryCollectionStateStub implements ICategoryCollectionState {
public withSelectedScripts(initialScripts: readonly SelectedScript[]): this {
return this.withSelection(
new UserSelectionStub([]).withSelectedScripts(initialScripts),
new UserSelectionStub().withScripts(
new ScriptSelectionStub()
.withSelectedScripts(initialScripts),
),
);
}
public withSelection(selection: IUserSelection) {
public withSelection(selection: UserSelection) {
this.selection = selection;
return this;
}

View File

@@ -33,6 +33,13 @@ export class CategoryCollectionStub implements ICategoryCollection {
return this;
}
public withActions(...actions: readonly ICategory[]): this {
for (const action of actions) {
this.withAction(action);
}
return this;
}
public withOs(os: OperatingSystem): this {
this.os = os;
return this;

View File

@@ -0,0 +1,33 @@
import { CategorySelection } from '@/application/Context/State/Selection/Category/CategorySelection';
import { CategorySelectionChangeCommand } from '@/application/Context/State/Selection/Category/CategorySelectionChange';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
export class CategorySelectionStub
extends StubWithObservableMethodCalls<CategorySelection>
implements CategorySelection {
public isCategorySelected(categoryId: number, revert: boolean): boolean {
const call = this.callHistory.find(
(c) => c.methodName === 'processChanges'
&& c.args[0].changes.some((change) => (
change.newStatus.isSelected === true
&& change.newStatus.isReverted === revert
&& change.categoryId === categoryId)),
);
return call !== undefined;
}
public areAllScriptsSelected(): boolean {
throw new Error('Method not implemented.');
}
public isAnyScriptSelected(): boolean {
throw new Error('Method not implemented.');
}
public processChanges(action: CategorySelectionChangeCommand): void {
this.registerMethodCall({
methodName: 'processChanges',
args: [action],
});
}
}

View File

@@ -12,6 +12,8 @@ export class CategoryStub extends BaseEntity<number> implements ICategory {
public readonly docs = new Array<string>();
private allScriptsRecursively: (readonly IScript[]) | undefined;
public constructor(id: number) {
super(id);
}
@@ -21,13 +23,16 @@ export class CategoryStub extends BaseEntity<number> implements ICategory {
}
public getAllScriptsRecursively(): readonly IScript[] {
return [
...this.scripts,
...this.subCategories.flatMap((c) => c.getAllScriptsRecursively()),
];
if (this.allScriptsRecursively === undefined) {
return [
...this.scripts,
...this.subCategories.flatMap((c) => c.getAllScriptsRecursively()),
];
}
return this.allScriptsRecursively;
}
public withScriptIds(...scriptIds: string[]): this {
public withScriptIds(...scriptIds: readonly string[]): this {
return this.withScripts(
...scriptIds.map((id) => new ScriptStub(id)),
);
@@ -40,6 +45,15 @@ export class CategoryStub extends BaseEntity<number> implements ICategory {
return this;
}
public withAllScriptIdsRecursively(...scriptIds: readonly string[]): this {
return this.withAllScriptsRecursively(...scriptIds.map((id) => new ScriptStub(id)));
}
public withAllScriptsRecursively(...scripts: IScript[]): this {
this.allScriptsRecursively = [...scripts];
return this;
}
public withMandatoryScripts(): this {
return this
.withScript(new ScriptStub(`[${CategoryStub.name}] script-1`).withLevel(RecommendationLevel.Standard))

View File

@@ -0,0 +1,89 @@
import { ScriptSelection } from '@/application/Context/State/Selection/Script/ScriptSelection';
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { IScript } from '@/domain/IScript';
import { ScriptSelectionChangeCommand } from '@/application/Context/State/Selection/Script/ScriptSelectionChange';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
import { EventSourceStub } from './EventSourceStub';
import { SelectedScriptStub } from './SelectedScriptStub';
export class ScriptSelectionStub
extends StubWithObservableMethodCalls<ScriptSelection>
implements ScriptSelection {
public readonly changed = new EventSourceStub<readonly SelectedScript[]>();
public selectedScripts: readonly SelectedScript[] = [];
public isSelectedResult: boolean | undefined;
public withSelectedScripts(selectedScripts: readonly SelectedScript[]): this {
this.selectedScripts = selectedScripts;
return this;
}
public triggerSelectionChangedEvent(scripts: readonly SelectedScript[]): this {
this.changed.notify(scripts);
return this;
}
public withIsSelectedResult(isSelected: boolean): this {
this.isSelectedResult = isSelected;
return this;
}
public isScriptSelected(scriptId: string, revert: boolean): boolean {
const call = this.callHistory.find(
(c) => c.methodName === 'processChanges'
&& c.args[0].changes.some((change) => (
change.newStatus.isSelected === true
&& change.newStatus.isReverted === revert
&& change.scriptId === scriptId)),
);
return call !== undefined;
}
public isScriptDeselected(scriptId: string): boolean {
const call = this.callHistory.find(
(c) => c.methodName === 'processChanges'
&& c.args[0].changes.some((change) => (
change.newStatus.isSelected === false
&& change.scriptId === scriptId)),
);
return call !== undefined;
}
public processChanges(action: ScriptSelectionChangeCommand): void {
this.registerMethodCall({
methodName: 'processChanges',
args: [action],
});
}
public selectOnly(scripts: ReadonlyArray<IScript>): void {
this.registerMethodCall({
methodName: 'selectOnly',
args: [scripts],
});
this.selectedScripts = scripts.map((s) => new SelectedScriptStub(s));
}
public selectAll(): void {
this.registerMethodCall({
methodName: 'selectAll',
args: [],
});
}
public deselectAll(): void {
this.registerMethodCall({
methodName: 'deselectAll',
args: [],
});
}
public isSelected(): boolean {
if (this.isSelectedResult === undefined) {
throw new Error('Method not configured.');
}
return this.isSelectedResult;
}
}

View File

@@ -1,8 +1,8 @@
import { BaseEntity } from '@/infrastructure/Entity/BaseEntity';
import { IScript } from '@/domain/IScript';
import { RecommendationLevel } from '@/domain/RecommendationLevel';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IScriptCode } from '@/domain/IScriptCode';
import { SelectedScriptStub } from './SelectedScriptStub';
export class ScriptStub extends BaseEntity<string> implements IScript {
public name = `name${this.id}`;
@@ -50,7 +50,7 @@ export class ScriptStub extends BaseEntity<string> implements IScript {
return this;
}
public toSelectedScript(isReverted = false): SelectedScript {
return new SelectedScript(this, isReverted);
public toSelectedScript(): SelectedScriptStub {
return new SelectedScriptStub(this);
}
}

View File

@@ -1,8 +1,26 @@
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { ScriptStub } from './ScriptStub';
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { IScript } from '@/domain/IScript';
export class SelectedScriptStub extends SelectedScript {
constructor(id: string, revert = false) {
super(new ScriptStub(id), revert);
export class SelectedScriptStub implements SelectedScript {
public readonly script: IScript;
public readonly id: string;
public revert: boolean;
constructor(
script: IScript,
) {
this.id = script.id;
this.script = script;
}
public withRevert(revert: boolean): this {
this.revert = revert;
return this;
}
public equals(): boolean {
throw new Error('Method not implemented.');
}
}

View File

@@ -17,7 +17,7 @@ export abstract class StubWithObservableMethodCalls<T> {
}
}
type MethodCall<T> = {
export type MethodCall<T> = {
[K in FunctionKeys<T>]: {
readonly methodName: K;
readonly args: T[K] extends (...args: infer A) => unknown ? A : never;

View File

@@ -0,0 +1,42 @@
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
import { EventSource } from '@/infrastructure/Events/EventSource';
import { TimeoutType, Timer } from '@/application/Common/Timing/Timer';
import { createMockTimeout } from './TimeoutStub';
export class TimerStub implements Timer {
private timeChanged = new EventSource<number>();
private subscriptions = new Array<IEventSubscription>();
private currentTime = 0;
public setTimeout(callback: () => void, ms: number): TimeoutType {
const runTime = this.currentTime + ms;
const subscription = this.timeChanged.on((time) => {
if (time >= runTime) {
callback();
subscription.unsubscribe();
}
});
this.subscriptions.push(subscription);
const id = this.subscriptions.length - 1;
return createMockTimeout(id);
}
public clearTimeout(timeoutId: TimeoutType): void {
this.subscriptions[+timeoutId].unsubscribe();
}
public dateNow(): number {
return this.currentTime;
}
public tickNext(ms: number): void {
this.setCurrentTime(this.currentTime + ms);
}
public setCurrentTime(ms: number): void {
this.currentTime = ms;
this.timeChanged.notify(this.currentTime);
}
}

View File

@@ -1,14 +1,15 @@
import { shallowRef } from 'vue';
import type { SelectionModifier, useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
import { UserSelectionStub } from './UserSelectionStub';
import { ScriptSelectionStub } from './ScriptSelectionStub';
export class UseUserSelectionStateStub
extends StubWithObservableMethodCalls<ReturnType<typeof useUserSelectionState>> {
private readonly currentSelection = shallowRef<IUserSelection>(
new UserSelectionStub([]),
private readonly currentSelection = shallowRef<UserSelection>(
new UserSelectionStub(),
);
private modifyCurrentSelection(mutator: SelectionModifier) {
@@ -19,19 +20,21 @@ export class UseUserSelectionStateStub
});
}
public withUserSelection(userSelection: IUserSelection): this {
public withUserSelection(userSelection: UserSelection): this {
this.currentSelection.value = userSelection;
return this;
}
public withSelectedScripts(selectedScripts: readonly SelectedScript[]): this {
return this.withUserSelection(
new UserSelectionStub(selectedScripts.map((s) => s.script))
.withSelectedScripts(selectedScripts),
new UserSelectionStub()
.withScripts(
new ScriptSelectionStub().withSelectedScripts(selectedScripts),
),
);
}
public get selection(): IUserSelection {
public get selection(): UserSelection {
return this.currentSelection.value;
}

View File

@@ -1,91 +1,21 @@
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IScript } from '@/domain/IScript';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
import { EventSourceStub } from './EventSourceStub';
import { CategorySelection } from '@/application/Context/State/Selection/Category/CategorySelection';
import { ScriptSelection } from '@/application/Context/State/Selection/Script/ScriptSelection';
import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
import { CategorySelectionStub } from './CategorySelectionStub';
import { ScriptSelectionStub } from './ScriptSelectionStub';
export class UserSelectionStub
extends StubWithObservableMethodCalls<IUserSelection>
implements IUserSelection {
public readonly changed = new EventSourceStub<readonly SelectedScript[]>();
export class UserSelectionStub implements UserSelection {
public categories: CategorySelection = new CategorySelectionStub();
public selectedScripts: readonly SelectedScript[] = [];
public scripts: ScriptSelection = new ScriptSelectionStub();
constructor(private readonly allScripts: readonly IScript[]) {
super();
}
public withSelectedScripts(selectedScripts: readonly SelectedScript[]): this {
this.selectedScripts = selectedScripts;
public withCategories(categories: CategorySelection): this {
this.categories = categories;
return this;
}
public triggerSelectionChangedEvent(scripts: readonly SelectedScript[]): this {
this.changed.notify(scripts);
public withScripts(scripts: ScriptSelection): this {
this.scripts = scripts;
return this;
}
public isScriptAdded(scriptId: string): boolean {
const call = this.callHistory.find(
(c) => c.methodName === 'addSelectedScript' && c.args[0] === scriptId,
);
return call !== undefined;
}
public isScriptRemoved(scriptId: string): boolean {
const call = this.callHistory.find(
(c) => c.methodName === 'removeSelectedScript' && c.args[0] === scriptId,
);
return call !== undefined;
}
public areAllSelected(): boolean {
throw new Error('Method not implemented.');
}
public isAnySelected(): boolean {
throw new Error('Method not implemented.');
}
public removeAllInCategory(): void {
throw new Error('Method not implemented.');
}
public addOrUpdateAllInCategory(): void {
throw new Error('Method not implemented.');
}
public addSelectedScript(scriptId: string, revert: boolean): void {
this.registerMethodCall({
methodName: 'addSelectedScript',
args: [scriptId, revert],
});
}
public addOrUpdateSelectedScript(): void {
throw new Error('Method not implemented.');
}
public removeSelectedScript(scriptId: string): void {
this.registerMethodCall({
methodName: 'removeSelectedScript',
args: [scriptId],
});
}
public selectOnly(scripts: ReadonlyArray<IScript>): void {
this.selectedScripts = scripts.map((s) => new SelectedScript(s, false));
}
public isSelected(): boolean {
throw new Error('Method not implemented.');
}
public selectAll(): void {
this.selectOnly(this.allScripts);
}
public deselectAll(): void {
this.selectedScripts = [];
}
}