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

@@ -81,8 +81,10 @@ These checks validate various qualities like runtime execution, building process
- [`Stubs/`](./../tests/unit/shared/Stubs): Maintains stubs for component isolation, equipped with basic functionalities and, when necessary, spying or mocking capabilities. - [`Stubs/`](./../tests/unit/shared/Stubs): Maintains stubs for component isolation, equipped with basic functionalities and, when necessary, spying or mocking capabilities.
- [`./tests/integration/`](./../tests/integration/): Contains integration test files. - [`./tests/integration/`](./../tests/integration/): Contains integration test files.
- [`cypress.config.ts`](./../cypress.config.ts): Cypress (E2E tests) configuration file. - [`cypress.config.ts`](./../cypress.config.ts): Cypress (E2E tests) configuration file.
- [`cypress-dirs.json`](./../cypress-dirs.json): A central definition of directories used by Cypress, designed for reuse across different configurations.
- [`./tests/e2e/`](./../tests/e2e/): Base Cypress folder, includes tests with `.cy.ts` extension. - [`./tests/e2e/`](./../tests/e2e/): Base Cypress folder, includes tests with `.cy.ts` extension.
- [`/support/e2e.ts`](./../tests/e2e/support/e2e.ts): Support file, runs before every single spec file.
- [`/tsconfig.json`]: TypeScript configuration for file Cypress code, improves IDE support, recommended to have by official documentation. - [`/tsconfig.json`]: TypeScript configuration for file Cypress code, improves IDE support, recommended to have by official documentation.
- *(git ignored)* `/videos`: Asset folder for videos taken during tests. - *(git ignored)* `/videos`: Asset folder for videos taken during tests.
- *(git ignored)* `/screenshots`: Asset folder for Screenshots taken during tests. - *(git ignored)* `/screenshots`: Asset folder for Screenshots taken during tests.
- [`/support/e2e.ts`](./../tests/e2e/support/e2e.ts): Support file, runs before every single test file.
- [`/support/interactions/`](./../tests/e2e/support/interactions/): Contains reusable functions for simulating user interactions, enhancing test readability and maintainability.

View File

@@ -0,0 +1,27 @@
import { PlatformTimer } from './PlatformTimer';
import { TimeoutType, Timer } from './Timer';
export function batchedDebounce<T>(
callback: (batches: readonly T[]) => void,
waitInMs: number,
timer: Timer = PlatformTimer,
): (arg: T) => void {
let lastTimeoutId: TimeoutType | undefined;
let batches: Array<T> = [];
return (arg: T) => {
batches.push(arg);
const later = () => {
callback(batches);
batches = [];
lastTimeoutId = undefined;
};
if (lastTimeoutId !== undefined) {
timer.clearTimeout(lastTimeoutId);
}
lastTimeoutId = timer.setTimeout(later, waitInMs);
};
}

View File

@@ -0,0 +1,7 @@
import { Timer } from './Timer';
export const PlatformTimer: Timer = {
setTimeout: (callback, ms) => setTimeout(callback, ms),
clearTimeout: (timeoutId) => clearTimeout(timeoutId),
dateNow: () => Date.now(),
};

View File

@@ -1,40 +1,24 @@
import { Timer, TimeoutType } from './Timer';
import { PlatformTimer } from './PlatformTimer';
export type CallbackType = (..._: unknown[]) => void; export type CallbackType = (..._: unknown[]) => void;
export function throttle( export function throttle(
callback: CallbackType, callback: CallbackType,
waitInMs: number, waitInMs: number,
timer: ITimer = NodeTimer, timer: Timer = PlatformTimer,
): CallbackType { ): CallbackType {
const throttler = new Throttler(timer, waitInMs, callback); const throttler = new Throttler(timer, waitInMs, callback);
return (...args: unknown[]) => throttler.invoke(...args); return (...args: unknown[]) => throttler.invoke(...args);
} }
// Allows aligning with both NodeJs (NodeJs.Timeout) and Window type (number) class Throttler {
export type Timeout = ReturnType<typeof setTimeout>; private queuedExecutionId: TimeoutType | undefined;
export interface ITimer {
setTimeout: (callback: () => void, ms: number) => Timeout;
clearTimeout: (timeoutId: Timeout) => void;
dateNow(): number;
}
const NodeTimer: ITimer = {
setTimeout: (callback, ms) => setTimeout(callback, ms),
clearTimeout: (timeoutId) => clearTimeout(timeoutId),
dateNow: () => Date.now(),
};
interface IThrottler {
invoke: CallbackType;
}
class Throttler implements IThrottler {
private queuedExecutionId: Timeout | undefined;
private previouslyRun: number; private previouslyRun: number;
constructor( constructor(
private readonly timer: ITimer, private readonly timer: Timer,
private readonly waitInMs: number, private readonly waitInMs: number,
private readonly callback: CallbackType, private readonly callback: CallbackType,
) { ) {

View File

@@ -0,0 +1,8 @@
// Allows aligning with both NodeJs (NodeJs.Timeout) and Window type (number)
export type TimeoutType = ReturnType<typeof setTimeout>;
export interface Timer {
setTimeout: (callback: () => void, ms: number) => TimeoutType;
clearTimeout: (timeoutId: TimeoutType) => void;
dateNow(): number;
}

View File

@@ -4,23 +4,48 @@ import { UserFilter } from './Filter/UserFilter';
import { IUserFilter } from './Filter/IUserFilter'; import { IUserFilter } from './Filter/IUserFilter';
import { ApplicationCode } from './Code/ApplicationCode'; import { ApplicationCode } from './Code/ApplicationCode';
import { UserSelection } from './Selection/UserSelection'; import { UserSelection } from './Selection/UserSelection';
import { IUserSelection } from './Selection/IUserSelection';
import { ICategoryCollectionState } from './ICategoryCollectionState'; import { ICategoryCollectionState } from './ICategoryCollectionState';
import { IApplicationCode } from './Code/IApplicationCode'; import { IApplicationCode } from './Code/IApplicationCode';
import { UserSelectionFacade } from './Selection/UserSelectionFacade';
export class CategoryCollectionState implements ICategoryCollectionState { export class CategoryCollectionState implements ICategoryCollectionState {
public readonly os: OperatingSystem; public readonly os: OperatingSystem;
public readonly code: IApplicationCode; public readonly code: IApplicationCode;
public readonly selection: IUserSelection; public readonly selection: UserSelection;
public readonly filter: IUserFilter; public readonly filter: IUserFilter;
public constructor(readonly collection: ICategoryCollection) { public constructor(
this.selection = new UserSelection(collection, []); public readonly collection: ICategoryCollection,
this.code = new ApplicationCode(this.selection, collection.scripting); selectionFactory = DefaultSelectionFactory,
this.filter = new UserFilter(collection); codeFactory = DefaultCodeFactory,
filterFactory = DefaultFilterFactory,
) {
this.selection = selectionFactory(collection, []);
this.code = codeFactory(this.selection.scripts, collection.scripting);
this.filter = filterFactory(collection);
this.os = collection.os; this.os = collection.os;
} }
} }
export type CodeFactory = (
...params: ConstructorParameters<typeof ApplicationCode>
) => IApplicationCode;
const DefaultCodeFactory: CodeFactory = (...params) => new ApplicationCode(...params);
export type SelectionFactory = (
...params: ConstructorParameters<typeof UserSelectionFacade>
) => UserSelection;
const DefaultSelectionFactory: SelectionFactory = (
...params
) => new UserSelectionFacade(...params);
export type FilterFactory = (
...params: ConstructorParameters<typeof UserFilter>
) => IUserFilter;
const DefaultFilterFactory: FilterFactory = (...params) => new UserFilter(...params);

View File

@@ -1,7 +1,7 @@
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IReadOnlyUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { EventSource } from '@/infrastructure/Events/EventSource'; import { EventSource } from '@/infrastructure/Events/EventSource';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition'; import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { ReadonlyScriptSelection } from '@/application/Context/State/Selection/Script/ScriptSelection';
import { CodeChangedEvent } from './Event/CodeChangedEvent'; import { CodeChangedEvent } from './Event/CodeChangedEvent';
import { CodePosition } from './Position/CodePosition'; import { CodePosition } from './Position/CodePosition';
import { ICodeChangedEvent } from './Event/ICodeChangedEvent'; import { ICodeChangedEvent } from './Event/ICodeChangedEvent';
@@ -17,12 +17,12 @@ export class ApplicationCode implements IApplicationCode {
private scriptPositions = new Map<SelectedScript, CodePosition>(); private scriptPositions = new Map<SelectedScript, CodePosition>();
constructor( constructor(
userSelection: IReadOnlyUserSelection, selection: ReadonlyScriptSelection,
private readonly scriptingDefinition: IScriptingDefinition, private readonly scriptingDefinition: IScriptingDefinition,
private readonly generator: IUserScriptGenerator = new UserScriptGenerator(), private readonly generator: IUserScriptGenerator = new UserScriptGenerator(),
) { ) {
this.setCode(userSelection.selectedScripts); this.setCode(selection.selectedScripts);
userSelection.changed.on((scripts) => { selection.changed.on((scripts) => {
this.setCode(scripts); this.setCode(scripts);
}); });
} }

View File

@@ -1,6 +1,6 @@
import { IScript } from '@/domain/IScript'; import { IScript } from '@/domain/IScript';
import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition'; import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
import { SelectedScript } from '../../Selection/SelectedScript'; import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { ICodeChangedEvent } from './ICodeChangedEvent'; import { ICodeChangedEvent } from './ICodeChangedEvent';
export class CodeChangedEvent implements ICodeChangedEvent { export class CodeChangedEvent implements ICodeChangedEvent {
@@ -36,7 +36,14 @@ export class CodeChangedEvent implements ICodeChangedEvent {
} }
public getScriptPositionInCode(script: IScript): ICodePosition { public getScriptPositionInCode(script: IScript): ICodePosition {
const position = this.scripts.get(script); return this.getPositionById(script.id);
}
private getPositionById(scriptId: string): ICodePosition {
const position = [...this.scripts.entries()]
.filter(([s]) => s.id === scriptId)
.map(([, pos]) => pos)
.at(0);
if (!position) { if (!position) {
throw new Error('Unknown script: Position could not be found for the script'); throw new Error('Unknown script: Position could not be found for the script');
} }

View File

@@ -3,9 +3,9 @@ import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePo
export interface ICodeChangedEvent { export interface ICodeChangedEvent {
readonly code: string; readonly code: string;
addedScripts: ReadonlyArray<IScript>; readonly addedScripts: ReadonlyArray<IScript>;
removedScripts: ReadonlyArray<IScript>; readonly removedScripts: ReadonlyArray<IScript>;
changedScripts: ReadonlyArray<IScript>; readonly changedScripts: ReadonlyArray<IScript>;
isEmpty(): boolean; isEmpty(): boolean;
getScriptPositionInCode(script: IScript): ICodePosition; getScriptPositionInCode(script: IScript): ICodePosition;
} }

View File

@@ -1,7 +1,7 @@
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition'; import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
export interface IUserScript { export interface IUserScript {
code: string; readonly code: string;
scriptPositions: Map<SelectedScript, ICodePosition>; readonly scriptPositions: Map<SelectedScript, ICodePosition>;
} }

View File

@@ -1,9 +1,10 @@
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition'; import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { IUserScript } from './IUserScript'; import { IUserScript } from './IUserScript';
export interface IUserScriptGenerator { export interface IUserScriptGenerator {
buildCode( buildCode(
selectedScripts: ReadonlyArray<SelectedScript>, selectedScripts: ReadonlyArray<SelectedScript>,
scriptingDefinition: IScriptingDefinition): IUserScript; scriptingDefinition: IScriptingDefinition,
): IUserScript;
} }

View File

@@ -1,6 +1,6 @@
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition'; import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition'; import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { CodePosition } from '../Position/CodePosition'; import { CodePosition } from '../Position/CodePosition';
import { IUserScriptGenerator } from './IUserScriptGenerator'; import { IUserScriptGenerator } from './IUserScriptGenerator';
import { IUserScript } from './IUserScript'; import { IUserScript } from './IUserScript';

View File

@@ -1,18 +1,18 @@
import { ICategoryCollection } from '@/domain/ICategoryCollection'; import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { IReadOnlyUserFilter, IUserFilter } from './Filter/IUserFilter'; import { IReadOnlyUserFilter, IUserFilter } from './Filter/IUserFilter';
import { IReadOnlyUserSelection, IUserSelection } from './Selection/IUserSelection'; import { ReadonlyUserSelection, UserSelection } from './Selection/UserSelection';
import { IApplicationCode } from './Code/IApplicationCode'; import { IApplicationCode } from './Code/IApplicationCode';
export interface IReadOnlyCategoryCollectionState { export interface IReadOnlyCategoryCollectionState {
readonly code: IApplicationCode; readonly code: IApplicationCode;
readonly os: OperatingSystem; readonly os: OperatingSystem;
readonly filter: IReadOnlyUserFilter; readonly filter: IReadOnlyUserFilter;
readonly selection: IReadOnlyUserSelection; readonly selection: ReadonlyUserSelection;
readonly collection: ICategoryCollection; readonly collection: ICategoryCollection;
} }
export interface ICategoryCollectionState extends IReadOnlyCategoryCollectionState { export interface ICategoryCollectionState extends IReadOnlyCategoryCollectionState {
readonly filter: IUserFilter; readonly filter: IUserFilter;
readonly selection: IUserSelection; readonly selection: UserSelection;
} }

View File

@@ -0,0 +1,11 @@
import { ICategory } from '@/domain/ICategory';
import { CategorySelectionChangeCommand } from './CategorySelectionChange';
export interface ReadonlyCategorySelection {
areAllScriptsSelected(category: ICategory): boolean;
isAnyScriptSelected(category: ICategory): boolean;
}
export interface CategorySelection extends ReadonlyCategorySelection {
processChanges(action: CategorySelectionChangeCommand): void;
}

View File

@@ -0,0 +1,15 @@
type CategorySelectionStatus = {
readonly isSelected: true;
readonly isReverted: boolean;
} | {
readonly isSelected: false;
};
export interface CategorySelectionChange {
readonly categoryId: number;
readonly newStatus: CategorySelectionStatus;
}
export interface CategorySelectionChangeCommand {
readonly changes: readonly CategorySelectionChange[];
}

View File

@@ -0,0 +1,60 @@
import { ICategory } from '@/domain/ICategory';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { ScriptSelection } from '../Script/ScriptSelection';
import { ScriptSelectionChange } from '../Script/ScriptSelectionChange';
import { CategorySelection } from './CategorySelection';
import { CategorySelectionChange, CategorySelectionChangeCommand } from './CategorySelectionChange';
export class ScriptToCategorySelectionMapper implements CategorySelection {
constructor(
private readonly scriptSelection: ScriptSelection,
private readonly collection: ICategoryCollection,
) {
}
public areAllScriptsSelected(category: ICategory): boolean {
const { selectedScripts } = this.scriptSelection;
if (selectedScripts.length === 0) {
return false;
}
const scripts = category.getAllScriptsRecursively();
if (selectedScripts.length < scripts.length) {
return false;
}
return scripts.every(
(script) => selectedScripts.some((selected) => selected.id === script.id),
);
}
public isAnyScriptSelected(category: ICategory): boolean {
const { selectedScripts } = this.scriptSelection;
if (selectedScripts.length === 0) {
return false;
}
return selectedScripts.some((s) => category.includes(s.script));
}
public processChanges(action: CategorySelectionChangeCommand): void {
const scriptChanges = action.changes.reduce((changes, change) => {
changes.push(...this.collectScriptChanges(change));
return changes;
}, new Array<ScriptSelectionChange>());
this.scriptSelection.processChanges({
changes: scriptChanges,
});
}
private collectScriptChanges(change: CategorySelectionChange): ScriptSelectionChange[] {
const category = this.collection.getCategory(change.categoryId);
const scripts = category.getAllScriptsRecursively();
const scriptsChangesInCategory = scripts
.map((script): ScriptSelectionChange => ({
scriptId: script.id,
newStatus: {
...change.newStatus,
},
}));
return scriptsChangesInCategory;
}
}

View File

@@ -1,23 +0,0 @@
import { IScript } from '@/domain/IScript';
import { ICategory } from '@/domain/ICategory';
import { IEventSource } from '@/infrastructure/Events/IEventSource';
import { SelectedScript } from './SelectedScript';
export interface IReadOnlyUserSelection {
readonly changed: IEventSource<ReadonlyArray<SelectedScript>>;
readonly selectedScripts: ReadonlyArray<SelectedScript>;
isSelected(scriptId: string): boolean;
areAllSelected(category: ICategory): boolean;
isAnySelected(category: ICategory): boolean;
}
export interface IUserSelection extends IReadOnlyUserSelection {
removeAllInCategory(categoryId: number): void;
addOrUpdateAllInCategory(categoryId: number, revert: boolean): void;
addSelectedScript(scriptId: string, revert: boolean): void;
addOrUpdateSelectedScript(scriptId: string, revert: boolean): void;
removeSelectedScript(scriptId: string): void;
selectOnly(scripts: ReadonlyArray<IScript>): void;
selectAll(): void;
deselectAll(): void;
}

View File

@@ -0,0 +1,171 @@
import { InMemoryRepository } from '@/infrastructure/Repository/InMemoryRepository';
import { IScript } from '@/domain/IScript';
import { EventSource } from '@/infrastructure/Events/EventSource';
import { ReadonlyRepository, Repository } from '@/application/Repository/Repository';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { batchedDebounce } from '@/application/Common/Timing/BatchedDebounce';
import { ScriptSelection } from './ScriptSelection';
import { ScriptSelectionChange, ScriptSelectionChangeCommand } from './ScriptSelectionChange';
import { SelectedScript } from './SelectedScript';
import { UserSelectedScript } from './UserSelectedScript';
const DEBOUNCE_DELAY_IN_MS = 100;
export type DebounceFunction = typeof batchedDebounce<ScriptSelectionChangeCommand>;
export class DebouncedScriptSelection implements ScriptSelection {
public readonly changed = new EventSource<ReadonlyArray<SelectedScript>>();
private readonly scripts: Repository<string, SelectedScript>;
public readonly processChanges: ScriptSelection['processChanges'];
constructor(
private readonly collection: ICategoryCollection,
selectedScripts: ReadonlyArray<SelectedScript>,
debounce: DebounceFunction = batchedDebounce,
) {
this.scripts = new InMemoryRepository<string, SelectedScript>();
for (const script of selectedScripts) {
this.scripts.addItem(script);
}
this.processChanges = debounce(
(batchedRequests: readonly ScriptSelectionChangeCommand[]) => {
const consolidatedChanges = batchedRequests.flatMap((request) => request.changes);
this.processScriptChanges(consolidatedChanges);
},
DEBOUNCE_DELAY_IN_MS,
);
}
public isSelected(scriptId: string): boolean {
return this.scripts.exists(scriptId);
}
public get selectedScripts(): readonly SelectedScript[] {
return this.scripts.getItems();
}
public selectAll(): void {
const scriptsToSelect = this.collection
.getAllScripts()
.filter((script) => !this.scripts.exists(script.id))
.map((script) => new UserSelectedScript(script, false));
if (scriptsToSelect.length === 0) {
return;
}
this.processChanges({
changes: scriptsToSelect.map((script): ScriptSelectionChange => ({
scriptId: script.id,
newStatus: {
isSelected: true,
isReverted: false,
},
})),
});
}
public deselectAll(): void {
if (this.scripts.length === 0) {
return;
}
const selectedScriptIds = this.scripts.getItems().map((script) => script.id);
this.processChanges({
changes: selectedScriptIds.map((scriptId): ScriptSelectionChange => ({
scriptId,
newStatus: {
isSelected: false,
},
})),
});
}
public selectOnly(scripts: readonly IScript[]): void {
if (scripts.length === 0) {
throw new Error('Provided script array is empty. To deselect all scripts, please use the deselectAll() method instead.');
}
this.processChanges({
changes: [
...getScriptIdsToBeDeselected(this.scripts, scripts)
.map((scriptId): ScriptSelectionChange => ({
scriptId,
newStatus: {
isSelected: false,
},
})),
...getScriptIdsToBeSelected(this.scripts, scripts)
.map((scriptId): ScriptSelectionChange => ({
scriptId,
newStatus: {
isSelected: true,
isReverted: false,
},
})),
],
});
}
private processScriptChanges(changes: readonly ScriptSelectionChange[]): void {
let totalChanged = 0;
for (const change of changes) {
totalChanged += this.applyChange(change);
}
if (totalChanged > 0) {
this.changed.notify(this.scripts.getItems());
}
}
private applyChange(change: ScriptSelectionChange): number {
const script = this.collection.getScript(change.scriptId);
if (change.newStatus.isSelected) {
return this.addOrUpdateScript(script.id, change.newStatus.isReverted);
}
return this.removeScript(script.id);
}
private addOrUpdateScript(scriptId: string, revert: boolean): number {
const script = this.collection.getScript(scriptId);
const selectedScript = new UserSelectedScript(script, revert);
if (!this.scripts.exists(selectedScript.id)) {
this.scripts.addItem(selectedScript);
return 1;
}
const existingSelectedScript = this.scripts.getById(selectedScript.id);
if (equals(selectedScript, existingSelectedScript)) {
return 0;
}
this.scripts.addOrUpdateItem(selectedScript);
return 1;
}
private removeScript(scriptId: string): number {
if (!this.scripts.exists(scriptId)) {
return 0;
}
this.scripts.removeItem(scriptId);
return 1;
}
}
function getScriptIdsToBeSelected(
existingItems: ReadonlyRepository<string, SelectedScript>,
desiredScripts: readonly IScript[],
): string[] {
return desiredScripts
.filter((script) => !existingItems.exists(script.id))
.map((script) => script.id);
}
function getScriptIdsToBeDeselected(
existingItems: ReadonlyRepository<string, SelectedScript>,
desiredScripts: readonly IScript[],
): string[] {
return existingItems
.getItems()
.filter((existing) => !desiredScripts.some((script) => existing.id === script.id))
.map((script) => script.id);
}
function equals(a: SelectedScript, b: SelectedScript): boolean {
return a.script.equals(b.script.id) && a.revert === b.revert;
}

View File

@@ -0,0 +1,17 @@
import { IEventSource } from '@/infrastructure/Events/IEventSource';
import { IScript } from '@/domain/IScript';
import { SelectedScript } from './SelectedScript';
import { ScriptSelectionChangeCommand } from './ScriptSelectionChange';
export interface ReadonlyScriptSelection {
readonly changed: IEventSource<readonly SelectedScript[]>;
readonly selectedScripts: readonly SelectedScript[];
isSelected(scriptId: string): boolean;
}
export interface ScriptSelection extends ReadonlyScriptSelection {
selectOnly(scripts: readonly IScript[]): void;
selectAll(): void;
deselectAll(): void;
processChanges(action: ScriptSelectionChangeCommand): void;
}

View File

@@ -0,0 +1,15 @@
export type ScriptSelectionStatus = {
readonly isSelected: true;
readonly isReverted: boolean;
} | {
readonly isSelected: false;
};
export interface ScriptSelectionChange {
readonly scriptId: string;
readonly newStatus: ScriptSelectionStatus;
}
export interface ScriptSelectionChangeCommand {
readonly changes: ReadonlyArray<ScriptSelectionChange>;
}

View File

@@ -0,0 +1,9 @@
import { IEntity } from '@/infrastructure/Entity/IEntity';
import { IScript } from '@/domain/IScript';
type ScriptId = IScript['id'];
export interface SelectedScript extends IEntity<ScriptId> {
readonly script: IScript;
readonly revert: boolean;
}

View File

@@ -1,14 +1,17 @@
import { BaseEntity } from '@/infrastructure/Entity/BaseEntity'; import { BaseEntity } from '@/infrastructure/Entity/BaseEntity';
import { IScript } from '@/domain/IScript'; import { IScript } from '@/domain/IScript';
import { SelectedScript } from './SelectedScript';
export class SelectedScript extends BaseEntity<string> { type SelectedScriptId = SelectedScript['id'];
export class UserSelectedScript extends BaseEntity<SelectedScriptId> {
constructor( constructor(
public readonly script: IScript, public readonly script: IScript,
public readonly revert: boolean, public readonly revert: boolean,
) { ) {
super(script.id); super(script.id);
if (revert && !script.canRevert()) { if (revert && !script.canRevert()) {
throw new Error('cannot revert an irreversible script'); throw new Error(`The script with ID '${script.id}' is not reversible and cannot be reverted.`);
} }
} }
} }

View File

@@ -1,164 +1,12 @@
import { InMemoryRepository } from '@/infrastructure/Repository/InMemoryRepository'; import { CategorySelection, ReadonlyCategorySelection } from './Category/CategorySelection';
import { IScript } from '@/domain/IScript'; import { ReadonlyScriptSelection, ScriptSelection } from './Script/ScriptSelection';
import { EventSource } from '@/infrastructure/Events/EventSource';
import { IRepository } from '@/infrastructure/Repository/IRepository';
import { ICategory } from '@/domain/ICategory';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { IUserSelection } from './IUserSelection';
import { SelectedScript } from './SelectedScript';
export class UserSelection implements IUserSelection { export interface ReadonlyUserSelection {
public readonly changed = new EventSource<ReadonlyArray<SelectedScript>>(); readonly categories: ReadonlyCategorySelection;
readonly scripts: ReadonlyScriptSelection;
private readonly scripts: IRepository<string, SelectedScript>; }
constructor( export interface UserSelection extends ReadonlyUserSelection {
private readonly collection: ICategoryCollection, readonly categories: CategorySelection;
selectedScripts: ReadonlyArray<SelectedScript>, readonly scripts: ScriptSelection;
) {
this.scripts = new InMemoryRepository<string, SelectedScript>();
for (const script of selectedScripts) {
this.scripts.addItem(script);
}
}
public areAllSelected(category: ICategory): boolean {
if (this.selectedScripts.length === 0) {
return false;
}
const scripts = category.getAllScriptsRecursively();
if (this.selectedScripts.length < scripts.length) {
return false;
}
return scripts.every(
(script) => this.selectedScripts.some((selected) => selected.id === script.id),
);
}
public isAnySelected(category: ICategory): boolean {
if (this.selectedScripts.length === 0) {
return false;
}
return this.selectedScripts.some((s) => category.includes(s.script));
}
public removeAllInCategory(categoryId: number): void {
const category = this.collection.getCategory(categoryId);
const scriptsToRemove = category.getAllScriptsRecursively()
.filter((script) => this.scripts.exists(script.id));
if (!scriptsToRemove.length) {
return;
}
for (const script of scriptsToRemove) {
this.scripts.removeItem(script.id);
}
this.changed.notify(this.scripts.getItems());
}
public addOrUpdateAllInCategory(categoryId: number, revert = false): void {
const scriptsToAddOrUpdate = this.collection
.getCategory(categoryId)
.getAllScriptsRecursively()
.filter(
(script) => !this.scripts.exists(script.id)
|| this.scripts.getById(script.id).revert !== revert,
)
.map((script) => new SelectedScript(script, revert));
if (!scriptsToAddOrUpdate.length) {
return;
}
for (const script of scriptsToAddOrUpdate) {
this.scripts.addOrUpdateItem(script);
}
this.changed.notify(this.scripts.getItems());
}
public addSelectedScript(scriptId: string, revert: boolean): void {
const script = this.collection.getScript(scriptId);
const selectedScript = new SelectedScript(script, revert);
this.scripts.addItem(selectedScript);
this.changed.notify(this.scripts.getItems());
}
public addOrUpdateSelectedScript(scriptId: string, revert: boolean): void {
const script = this.collection.getScript(scriptId);
const selectedScript = new SelectedScript(script, revert);
this.scripts.addOrUpdateItem(selectedScript);
this.changed.notify(this.scripts.getItems());
}
public removeSelectedScript(scriptId: string): void {
this.scripts.removeItem(scriptId);
this.changed.notify(this.scripts.getItems());
}
public isSelected(scriptId: string): boolean {
return this.scripts.exists(scriptId);
}
/** Get users scripts based on his/her selections */
public get selectedScripts(): ReadonlyArray<SelectedScript> {
return this.scripts.getItems();
}
public selectAll(): void {
const scriptsToSelect = this.collection
.getAllScripts()
.filter((script) => !this.scripts.exists(script.id))
.map((script) => new SelectedScript(script, false));
if (scriptsToSelect.length === 0) {
return;
}
for (const script of scriptsToSelect) {
this.scripts.addItem(script);
}
this.changed.notify(this.scripts.getItems());
}
public deselectAll(): void {
if (this.scripts.length === 0) {
return;
}
const selectedScriptIds = this.scripts.getItems().map((script) => script.id);
for (const scriptId of selectedScriptIds) {
this.scripts.removeItem(scriptId);
}
this.changed.notify([]);
}
public selectOnly(scripts: readonly IScript[]): void {
if (!scripts.length) {
throw new Error('Scripts are empty. Use deselectAll() if you want to deselect everything');
}
let totalChanged = 0;
totalChanged += this.unselectMissingWithoutNotifying(scripts);
totalChanged += this.selectNewWithoutNotifying(scripts);
if (totalChanged > 0) {
this.changed.notify(this.scripts.getItems());
}
}
private unselectMissingWithoutNotifying(scripts: readonly IScript[]): number {
if (this.scripts.length === 0 || scripts.length === 0) {
return 0;
}
const existingItems = this.scripts.getItems();
const missingIds = existingItems
.filter((existing) => !scripts.some((script) => existing.id === script.id))
.map((script) => script.id);
for (const id of missingIds) {
this.scripts.removeItem(id);
}
return missingIds.length;
}
private selectNewWithoutNotifying(scripts: readonly IScript[]): number {
const unselectedScripts = scripts
.filter((script) => !this.scripts.exists(script.id))
.map((script) => new SelectedScript(script, false));
for (const newScript of unselectedScripts) {
this.scripts.addItem(newScript);
}
return unselectedScripts.length;
}
} }

View File

@@ -0,0 +1,39 @@
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { CategorySelection } from './Category/CategorySelection';
import { ScriptToCategorySelectionMapper } from './Category/ScriptToCategorySelectionMapper';
import { DebouncedScriptSelection } from './Script/DebouncedScriptSelection';
import { ScriptSelection } from './Script/ScriptSelection';
import { UserSelection } from './UserSelection';
import { SelectedScript } from './Script/SelectedScript';
export class UserSelectionFacade implements UserSelection {
public readonly categories: CategorySelection;
public readonly scripts: ScriptSelection;
constructor(
collection: ICategoryCollection,
selectedScripts: readonly SelectedScript[],
scriptsFactory = DefaultScriptsFactory,
categoriesFactory = DefaultCategoriesFactory,
) {
this.scripts = scriptsFactory(collection, selectedScripts);
this.categories = categoriesFactory(this.scripts, collection);
}
}
export type ScriptsFactory = (
...params: ConstructorParameters<typeof DebouncedScriptSelection>
) => ScriptSelection;
const DefaultScriptsFactory: ScriptsFactory = (
...params
) => new DebouncedScriptSelection(...params);
export type CategoriesFactory = (
...params: ConstructorParameters<typeof ScriptToCategorySelectionMapper>
) => CategorySelection;
const DefaultCategoriesFactory: CategoriesFactory = (
...params
) => new ScriptToCategorySelectionMapper(...params);

View File

@@ -0,0 +1,17 @@
import { IEntity } from '@/infrastructure/Entity/IEntity';
export interface ReadonlyRepository<TKey, TEntity extends IEntity<TKey>> {
readonly length: number;
getItems(predicate?: (entity: TEntity) => boolean): readonly TEntity[];
getById(id: TKey): TEntity;
exists(id: TKey): boolean;
}
export interface MutableRepository<TKey, TEntity extends IEntity<TKey>> {
addItem(item: TEntity): void;
addOrUpdateItem(item: TEntity): void;
removeItem(id: TKey): void;
}
export interface Repository<TKey, TEntity extends IEntity<TKey>>
extends ReadonlyRepository<TKey, TEntity>, MutableRepository<TKey, TEntity> { }

View File

@@ -1,11 +0,0 @@
import { IEntity } from '../Entity/IEntity';
export interface IRepository<TKey, TEntity extends IEntity<TKey>> {
readonly length: number;
getItems(predicate?: (entity: TEntity) => boolean): TEntity[];
getById(id: TKey): TEntity;
addItem(item: TEntity): void;
addOrUpdateItem(item: TEntity): void;
removeItem(id: TKey): void;
exists(id: TKey): boolean;
}

View File

@@ -1,8 +1,8 @@
import { IEntity } from '../Entity/IEntity'; import { IEntity } from '../Entity/IEntity';
import { IRepository } from './IRepository'; import { Repository } from '../../application/Repository/Repository';
export class InMemoryRepository<TKey, TEntity extends IEntity<TKey>> export class InMemoryRepository<TKey, TEntity extends IEntity<TKey>>
implements IRepository<TKey, TEntity> { implements Repository<TKey, TEntity> {
private readonly items: TEntity[]; private readonly items: TEntity[];
constructor(items?: TEntity[]) { constructor(items?: TEntity[]) {

View File

@@ -3,8 +3,10 @@
v-non-collapsing v-non-collapsing
@size-changed="sizeChanged()" @size-changed="sizeChanged()"
> >
<!-- `data-test-highlighted-range` is a test hook for assessing highlighted text range -->
<div <div
:id="editorId" :id="editorId"
:data-test-highlighted-range="highlightedRange"
class="code-area" class="code-area"
/> />
</SizeObserver> </SizeObserver>
@@ -12,7 +14,7 @@
<script lang="ts"> <script lang="ts">
import { import {
defineComponent, onUnmounted, onMounted, defineComponent, onUnmounted, onMounted, ref,
} from 'vue'; } from 'vue';
import { injectKey } from '@/presentation/injectionSymbols'; import { injectKey } from '@/presentation/injectionSymbols';
import { ICodeChangedEvent } from '@/application/Context/State/Code/Event/ICodeChangedEvent'; import { ICodeChangedEvent } from '@/application/Context/State/Code/Event/ICodeChangedEvent';
@@ -42,6 +44,8 @@ export default defineComponent({
const { events } = injectKey((keys) => keys.useAutoUnsubscribedEvents); const { events } = injectKey((keys) => keys.useAutoUnsubscribedEvents);
const editorId = 'codeEditor'; const editorId = 'codeEditor';
const highlightedRange = ref(0);
let editor: ace.Ace.Editor | undefined; let editor: ace.Ace.Editor | undefined;
let currentMarkerId: number | undefined; let currentMarkerId: number | undefined;
@@ -99,6 +103,7 @@ export default defineComponent({
} }
editor?.session.removeMarker(currentMarkerId); editor?.session.removeMarker(currentMarkerId);
currentMarkerId = undefined; currentMarkerId = undefined;
highlightedRange.value = 0;
} }
function reactToChanges(event: ICodeChangedEvent, scripts: ReadonlyArray<IScript>) { function reactToChanges(event: ICodeChangedEvent, scripts: ReadonlyArray<IScript>) {
@@ -121,6 +126,7 @@ export default defineComponent({
'code-area__highlight', 'code-area__highlight',
'fullLine', 'fullLine',
); );
highlightedRange.value = endRow - startRow;
} }
function scrollToLine(row: number) { function scrollToLine(row: number) {
@@ -133,6 +139,7 @@ export default defineComponent({
return { return {
editorId, editorId,
highlightedRange,
sizeChanged, sizeChanged,
}; };
}, },

View File

@@ -1,9 +1,9 @@
import { IScript } from '@/domain/IScript'; import { IScript } from '@/domain/IScript';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { RecommendationLevel } from '@/domain/RecommendationLevel'; import { RecommendationLevel } from '@/domain/RecommendationLevel';
import { scrambledEqual } from '@/application/Common/Array'; import { scrambledEqual } from '@/application/Common/Array';
import { IReadOnlyUserSelection, IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { ICategoryCollection } from '@/domain/ICategoryCollection'; import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { ReadonlyScriptSelection, ScriptSelection } from '@/application/Context/State/Selection/Script/ScriptSelection';
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
export enum SelectionType { export enum SelectionType {
Standard, Standard,
@@ -34,12 +34,12 @@ export function getCurrentSelectionType(context: SelectionCheckContext): Selecti
} }
export interface SelectionCheckContext { export interface SelectionCheckContext {
readonly selection: IReadOnlyUserSelection; readonly selection: ReadonlyScriptSelection;
readonly collection: ICategoryCollection; readonly collection: ICategoryCollection;
} }
export interface SelectionMutationContext { export interface SelectionMutationContext {
readonly selection: IUserSelection, readonly selection: ScriptSelection,
readonly collection: ICategoryCollection, readonly collection: ICategoryCollection,
} }

View File

@@ -91,7 +91,7 @@ export default defineComponent({
const currentSelectionType = computed<SelectionType>({ const currentSelectionType = computed<SelectionType>({
get: () => getCurrentSelectionType({ get: () => getCurrentSelectionType({
selection: currentSelection.value, selection: currentSelection.value.scripts,
collection: currentCollection.value, collection: currentCollection.value,
}), }),
set: (type: SelectionType) => { set: (type: SelectionType) => {
@@ -105,7 +105,7 @@ export default defineComponent({
} }
modifyCurrentSelection((mutableSelection) => { modifyCurrentSelection((mutableSelection) => {
setCurrentSelectionType(type, { setCurrentSelectionType(type, {
selection: mutableSelection, selection: mutableSelection.scripts,
collection: currentCollection.value, collection: currentCollection.value,
}); });
}); });

View File

@@ -38,11 +38,11 @@ export default defineComponent({
); );
const isAnyChildSelected = computed<boolean>( const isAnyChildSelected = computed<boolean>(
() => currentSelection.value.isAnySelected(currentCategory.value), () => currentSelection.value.categories.isAnyScriptSelected(currentCategory.value),
); );
const areAllChildrenSelected = computed<boolean>( const areAllChildrenSelected = computed<boolean>(
() => currentSelection.value.areAllSelected(currentCategory.value), () => currentSelection.value.categories.areAllScriptsSelected(currentCategory.value),
); );
return { return {

View File

@@ -41,7 +41,7 @@ export default defineComponent({
const isReverted = computed<boolean>({ const isReverted = computed<boolean>({
get() { get() {
const { selectedScripts } = currentSelection.value; const { selectedScripts } = currentSelection.value.scripts;
return revertHandler.value.getState(selectedScripts); return revertHandler.value.getState(selectedScripts);
}, },
set: (value: boolean) => { set: (value: boolean) => {

View File

@@ -1,6 +1,6 @@
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript'; import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { ICategoryCollection } from '@/domain/ICategoryCollection'; import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { getCategoryId } from '../../TreeViewAdapter/CategoryNodeMetadataConverter'; import { getCategoryId } from '../../TreeViewAdapter/CategoryNodeMetadataConverter';
import { IReverter } from './IReverter'; import { IReverter } from './IReverter';
import { ScriptReverter } from './ScriptReverter'; import { ScriptReverter } from './ScriptReverter';
@@ -19,8 +19,16 @@ export class CategoryReverter implements IReverter {
return this.scriptReverters.every((script) => script.getState(selectedScripts)); return this.scriptReverters.every((script) => script.getState(selectedScripts));
} }
public selectWithRevertState(newState: boolean, selection: IUserSelection): void { public selectWithRevertState(newState: boolean, selection: UserSelection): void {
selection.addOrUpdateAllInCategory(this.categoryId, newState); selection.categories.processChanges({
changes: [{
categoryId: this.categoryId,
newStatus: {
isSelected: true,
isReverted: newState,
},
}],
});
} }
} }

View File

@@ -1,7 +1,7 @@
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection'; import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript'; import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
export interface IReverter { export interface IReverter {
getState(selectedScripts: ReadonlyArray<SelectedScript>): boolean; getState(selectedScripts: ReadonlyArray<SelectedScript>): boolean;
selectWithRevertState(newState: boolean, selection: IUserSelection): void; selectWithRevertState(newState: boolean, selection: UserSelection): void;
} }

View File

@@ -1,5 +1,5 @@
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript'; import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection'; import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { getScriptId } from '../../TreeViewAdapter/CategoryNodeMetadataConverter'; import { getScriptId } from '../../TreeViewAdapter/CategoryNodeMetadataConverter';
import { IReverter } from './IReverter'; import { IReverter } from './IReverter';
@@ -18,7 +18,15 @@ export class ScriptReverter implements IReverter {
return selectedScript.revert; return selectedScript.revert;
} }
public selectWithRevertState(newState: boolean, selection: IUserSelection): void { public selectWithRevertState(newState: boolean, selection: UserSelection): void {
selection.addOrUpdateSelectedScript(this.scriptId, newState); selection.scripts.processChanges({
changes: [{
scriptId: this.scriptId,
newStatus: {
isSelected: true,
isReverted: newState,
},
}],
});
} }
} }

View File

@@ -16,19 +16,38 @@ export function useCollectionSelectionStateUpdater(
return; return;
} }
if (node.state.current.checkState === TreeNodeCheckState.Checked) { if (node.state.current.checkState === TreeNodeCheckState.Checked) {
if (currentSelection.value.isSelected(node.id)) { if (currentSelection.value.scripts.isSelected(node.id)) {
return; return;
} }
modifyCurrentSelection((selection) => { modifyCurrentSelection((selection) => {
selection.addSelectedScript(node.id, false); selection.scripts.processChanges({
changes: [
{
scriptId: node.id,
newStatus: {
isSelected: true,
isReverted: false,
},
},
],
});
}); });
} }
if (node.state.current.checkState === TreeNodeCheckState.Unchecked) { if (node.state.current.checkState === TreeNodeCheckState.Unchecked) {
if (!currentSelection.value.isSelected(node.id)) { if (!currentSelection.value.scripts.isSelected(node.id)) {
return; return;
} }
modifyCurrentSelection((selection) => { modifyCurrentSelection((selection) => {
selection.removeSelectedScript(node.id); selection.scripts.processChanges({
changes: [
{
scriptId: node.id,
newStatus: {
isSelected: false,
},
},
],
});
}); });
} }
} }

View File

@@ -13,6 +13,7 @@ export function useSelectedScriptNodeIds(
const selectedNodeIds = computed<readonly string[]>(() => { const selectedNodeIds = computed<readonly string[]>(() => {
return currentSelection return currentSelection
.value .value
.scripts
.selectedScripts .selectedScripts
.map((selected) => scriptNodeIdParser(selected.script)); .map((selected) => scriptNodeIdParser(selected.script));
}); });

View File

@@ -1,5 +1,5 @@
import { shallowReadonly, shallowRef, triggerRef } from 'vue'; import { shallowReadonly, shallowRef, triggerRef } from 'vue';
import { IReadOnlyUserSelection, IUserSelection } from '@/application/Context/State/Selection/IUserSelection'; import type { ReadonlyUserSelection, UserSelection } from '@/application/Context/State/Selection/UserSelection';
import type { useAutoUnsubscribedEvents } from './UseAutoUnsubscribedEvents'; import type { useAutoUnsubscribedEvents } from './UseAutoUnsubscribedEvents';
import type { useCollectionState } from './UseCollectionState'; import type { useCollectionState } from './UseCollectionState';
@@ -10,12 +10,12 @@ export function useUserSelectionState(
const { events } = autoUnsubscribedEvents; const { events } = autoUnsubscribedEvents;
const { onStateChange, modifyCurrentState, currentState } = collectionState; const { onStateChange, modifyCurrentState, currentState } = collectionState;
const currentSelection = shallowRef<IReadOnlyUserSelection>(currentState.value.selection); const currentSelection = shallowRef<ReadonlyUserSelection>(currentState.value.selection);
onStateChange((state) => { onStateChange((state) => {
updateSelection(state.selection); updateSelection(state.selection);
events.unsubscribeAllAndRegister([ events.unsubscribeAllAndRegister([
state.selection.changed.on(() => { state.selection.scripts.changed.on(() => {
updateSelection(state.selection); updateSelection(state.selection);
}), }),
]); ]);
@@ -27,7 +27,7 @@ export function useUserSelectionState(
}); });
} }
function updateSelection(newSelection: IReadOnlyUserSelection) { function updateSelection(newSelection: ReadonlyUserSelection) {
if (currentSelection.value === newSelection) { if (currentSelection.value === newSelection) {
// Do not trust Vue tracking, the changed selection object // Do not trust Vue tracking, the changed selection object
// reference may stay same for same collection. // reference may stay same for same collection.
@@ -44,5 +44,5 @@ export function useUserSelectionState(
} }
export type SelectionModifier = ( export type SelectionModifier = (
state: IUserSelection, state: UserSelection,
) => void; ) => void;

View File

@@ -9,7 +9,7 @@ import {
defineComponent, shallowRef, onMounted, onBeforeUnmount, watch, defineComponent, shallowRef, onMounted, onBeforeUnmount, watch,
} from 'vue'; } from 'vue';
import { useResizeObserverPolyfill } from '@/presentation/components/Shared/Hooks/UseResizeObserverPolyfill'; import { useResizeObserverPolyfill } from '@/presentation/components/Shared/Hooks/UseResizeObserverPolyfill';
import { throttle } from '@/presentation/components/Shared/Throttle'; import { throttle } from '@/application/Common/Timing/Throttle';
export default defineComponent({ export default defineComponent({
emits: { emits: {

View File

@@ -1,5 +1,5 @@
// eslint-disable-next-line max-classes-per-file // eslint-disable-next-line max-classes-per-file
import { waitForHeaderBrandTitle } from './shared/ApplicationLoad'; import { getHeaderBrandTitle } from './support/interactions/header';
interface Stoppable { interface Stoppable {
stop(): void; stop(): void;
@@ -175,7 +175,7 @@ enum ApplicationLoadStep {
const checkpoints: Record<ApplicationLoadStep, () => void> = { const checkpoints: Record<ApplicationLoadStep, () => void> = {
[ApplicationLoadStep.IndexHtmlLoaded]: () => cy.get('#app').should('be.visible'), [ApplicationLoadStep.IndexHtmlLoaded]: () => cy.get('#app').should('be.visible'),
[ApplicationLoadStep.AppVueLoaded]: () => cy.get('.app__wrapper').should('be.visible'), [ApplicationLoadStep.AppVueLoaded]: () => cy.get('.app__wrapper').should('be.visible'),
[ApplicationLoadStep.HeaderBrandTitleLoaded]: () => waitForHeaderBrandTitle(), [ApplicationLoadStep.HeaderBrandTitleLoaded]: () => getHeaderBrandTitle(),
}; };
class ContinuousRunner implements Stoppable { 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', () => { describe('application is initialized as expected', () => {
it('loads title as expected', () => { it('loads title as expected', () => {
// act // act
cy.visit('/'); cy.visit('/');
// assert // assert
waitForHeaderBrandTitle(); getHeaderBrandTitle();
}); });
it('there are no console.error output', () => { it('there are no console.error output', () => {
// act // act

View File

@@ -1,12 +1,13 @@
import { expectExists } from '@tests/shared/Assertions/ExpectExists'; import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { openCard } from './support/interactions/card';
describe('revert toggle', () => { describe('revert toggle', () => {
context('toggle switch', () => { context('toggle switch', () => {
beforeEach(() => { beforeEach(() => {
cy.visit('/'); cy.visit('/');
cy.get('.card') openCard({
.eq(1) // to get 2nd element, first is often cleanup that may lack revert button cardIndex: 1, // first is often cleanup that may lack revert button
.click(); // open the card card });
cy.get('.toggle-switch') cy.get('.toggle-switch')
.first() .first()
.as('toggleSwitch'); .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 { describe, it, expect } from 'vitest';
import { throttle, ITimer, Timeout } from '@/presentation/components/Shared/Throttle'; import { TimerStub } from '@tests/unit/shared/Stubs/TimerStub';
import { EventSource } from '@/infrastructure/Events/EventSource'; import { throttle } from '@/application/Common/Timing/Throttle';
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
import { createMockTimeout } from '@tests/unit/shared/Stubs/TimeoutStub';
describe('throttle', () => { describe('throttle', () => {
describe('validates parameters', () => { describe('validates parameters', () => {
@@ -34,7 +32,7 @@ describe('throttle', () => {
}); });
it('should call the callback immediately', () => { it('should call the callback immediately', () => {
// arrange // arrange
const timer = new TimerMock(); const timer = new TimerStub();
let totalRuns = 0; let totalRuns = 0;
const callback = () => totalRuns++; const callback = () => totalRuns++;
const throttleFunc = throttle(callback, 500, timer); const throttleFunc = throttle(callback, 500, timer);
@@ -45,7 +43,7 @@ describe('throttle', () => {
}); });
it('should call the callback again after the timeout', () => { it('should call the callback again after the timeout', () => {
// arrange // arrange
const timer = new TimerMock(); const timer = new TimerStub();
let totalRuns = 0; let totalRuns = 0;
const callback = () => totalRuns++; const callback = () => totalRuns++;
const waitInMs = 500; const waitInMs = 500;
@@ -60,7 +58,7 @@ describe('throttle', () => {
}); });
it('should call the callback at most once at given time', () => { it('should call the callback at most once at given time', () => {
// arrange // arrange
const timer = new TimerMock(); const timer = new TimerStub();
let totalRuns = 0; let totalRuns = 0;
const callback = () => totalRuns++; const callback = () => totalRuns++;
const waitInMs = 500; const waitInMs = 500;
@@ -77,7 +75,7 @@ describe('throttle', () => {
}); });
it('should call the callback as long as delay is waited', () => { it('should call the callback as long as delay is waited', () => {
// arrange // arrange
const timer = new TimerMock(); const timer = new TimerStub();
let totalRuns = 0; let totalRuns = 0;
const callback = () => totalRuns++; const callback = () => totalRuns++;
const waitInMs = 500; const waitInMs = 500;
@@ -93,7 +91,7 @@ describe('throttle', () => {
}); });
it('should call arguments as expected', () => { it('should call arguments as expected', () => {
// arrange // arrange
const timer = new TimerMock(); const timer = new TimerStub();
const expected = [1, 2, 3]; const expected = [1, 2, 3];
const actual = new Array<number>(); const actual = new Array<number>();
const callback = (arg: number) => { actual.push(arg); }; const callback = (arg: number) => { actual.push(arg); };
@@ -108,41 +106,3 @@ describe('throttle', () => {
expect(expected).to.deep.equal(actual); 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 { describe, it, expect } from 'vitest';
import { UserSelection } from '@/application/Context/State/Selection/UserSelection'; import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub';
import { ApplicationCode } from '@/application/Context/State/Code/ApplicationCode';
import { CategoryCollectionState } from '@/application/Context/State/CategoryCollectionState'; import { CategoryCollectionState } from '@/application/Context/State/CategoryCollectionState';
import { OperatingSystem } from '@/domain/OperatingSystem'; 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 { 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('CategoryCollectionState', () => {
describe('code', () => { describe('code', () => {
it('initialized with empty code', () => { it('uses the correct scripting definition', () => {
// 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', () => {
// arrange // arrange
const expectedScripting = new ScriptingDefinitionStub();
const collection = new CategoryCollectionStub() const collection = new CategoryCollectionStub()
.withAction(new CategoryStub(0).withScriptIds('scriptId')); .withScripting(expectedScripting);
const selectionStub = new UserSelection(collection, []); let actualScripting: IScriptingDefinition | undefined;
const expectedCodeGenerator = new ApplicationCode(selectionStub, collection.scripting); const codeFactoryMock: CodeFactory = (_, scripting) => {
selectionStub.selectAll(); actualScripting = scripting;
const expectedCode = expectedCodeGenerator.current; return new ApplicationCodeStub();
};
// act // act
const sut = new CategoryCollectionState(collection); new CategoryCollectionStateBuilder()
sut.selection.selectAll(); .withCollection(collection)
const actualCode = sut.code.current; .withCodeFactory(codeFactoryMock)
.build();
// assert // 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', () => { describe('os', () => {
it('same as its collection', () => { it('matches the operating system of the collection', () => {
// arrange // arrange
const expected = OperatingSystem.macOS; const expected = OperatingSystem.macOS;
const collection = new CategoryCollectionStub() const collection = new CategoryCollectionStub()
.withOs(expected); .withOs(expected);
// act // act
const sut = new CategoryCollectionState(collection); const sut = new CategoryCollectionStateBuilder()
.withCollection(collection)
.build();
// assert // assert
const actual = sut.os; const actual = sut.os;
expect(expected).to.equal(actual); expect(expected).to.equal(actual);
}); });
}); });
describe('selection', () => { describe('selection', () => {
it('initialized with no selection', () => { it('initializes with empty scripts', () => {
// arrange // arrange
const collection = new CategoryCollectionStub(); const expectedScripts = [];
const sut = new CategoryCollectionState(collection); let actualScripts: readonly SelectedScript[] | undefined;
const selectionFactoryMock: SelectionFactory = (_, scripts) => {
actualScripts = scripts;
return new UserSelectionStub();
};
// act // act
const actual = sut.selection.selectedScripts.length; new CategoryCollectionStateBuilder()
.withSelectionFactory(selectionFactoryMock)
.build();
// assert // 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 // arrange
const expectedScript = new ScriptStub('scriptId'); const expectedCollection = new CategoryCollectionStub();
const collection = new CategoryCollectionStub() let actualCollection: ICategoryCollection | undefined;
.withAction(new CategoryStub(0).withScript(expectedScript)); const selectionFactoryMock: SelectionFactory = (collection) => {
const sut = new CategoryCollectionState(collection); actualCollection = collection;
return new UserSelectionStub();
};
// act // act
sut.selection.selectAll(); new CategoryCollectionStateBuilder()
.withCollection(expectedCollection)
.withSelectionFactory(selectionFactoryMock)
.build();
// assert // assert
expect(sut.selection.selectedScripts.length).to.equal(1); expectExists(actualCollection);
expect(sut.selection.isSelected(expectedScript.id)).to.equal(true); expect(actualCollection).to.equal(expectedCollection);
}); });
}); });
describe('filter', () => { describe('filter', () => {
it('initialized with an empty filter', () => { it('initializes with the provided collection for filtering', () => {
// arrange // arrange
const collection = new CategoryCollectionStub(); const expectedCollection = new CategoryCollectionStub();
const sut = new CategoryCollectionState(collection); let actualCollection: ICategoryCollection | undefined;
const filterFactoryMock: FilterFactory = (collection) => {
actualCollection = collection;
return new UserFilterStub();
};
// act // act
const actual = sut.filter.currentFilter; new CategoryCollectionStateBuilder()
.withCollection(expectedCollection)
.withFilterFactory(filterFactoryMock)
.build();
// assert // assert
expect(actual).to.equal(undefined); expectExists(expectedCollection);
}); expect(expectedCollection).to.equal(actualCollection);
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);
}); });
}); });
}); });
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 { describe, it, expect } from 'vitest';
import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
import { ApplicationCode } from '@/application/Context/State/Code/ApplicationCode'; 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 { ICodeChangedEvent } from '@/application/Context/State/Code/Event/ICodeChangedEvent';
import { IUserScriptGenerator } from '@/application/Context/State/Code/Generation/IUserScriptGenerator'; import { IUserScriptGenerator } from '@/application/Context/State/Code/Generation/IUserScriptGenerator';
import { CodePosition } from '@/application/Context/State/Code/Position/CodePosition'; 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 { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { IUserScript } from '@/application/Context/State/Code/Generation/IUserScript'; import { IUserScript } from '@/application/Context/State/Code/Generation/IUserScript';
import { ScriptingDefinitionStub } from '@tests/unit/shared/Stubs/ScriptingDefinitionStub'; 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 { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
import { expectExists } from '@tests/shared/Assertions/ExpectExists'; 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('ApplicationCode', () => {
describe('ctor', () => { describe('ctor', () => {
it('empty when selection is empty', () => { it('empty when selection is empty', () => {
// arrange // arrange
const selection = new UserSelection(new CategoryCollectionStub(), []); const selectedScripts = [];
const selection = new ScriptSelectionStub()
.withSelectedScripts(selectedScripts);
const definition = new ScriptingDefinitionStub(); const definition = new ScriptingDefinitionStub();
const sut = new ApplicationCode(selection, definition); const sut = new ApplicationCode(selection, definition);
// act // act
@@ -29,10 +29,9 @@ describe('ApplicationCode', () => {
it('generates code from script generator when selection is not empty', () => { it('generates code from script generator when selection is not empty', () => {
// arrange // arrange
const scripts = [new ScriptStub('first'), new ScriptStub('second')]; 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 selectedScripts = scripts.map((script) => script.toSelectedScript());
const selection = new UserSelection(collection, selectedScripts); const selection = new ScriptSelectionStub()
.withSelectedScripts(selectedScripts);
const definition = new ScriptingDefinitionStub(); const definition = new ScriptingDefinitionStub();
const expected: IUserScript = { const expected: IUserScript = {
code: 'expected-code', code: 'expected-code',
@@ -53,10 +52,9 @@ describe('ApplicationCode', () => {
// arrange // arrange
let signaled: ICodeChangedEvent | undefined; let signaled: ICodeChangedEvent | undefined;
const scripts = [new ScriptStub('first'), new ScriptStub('second')]; const scripts = [new ScriptStub('first'), new ScriptStub('second')];
const collection = new CategoryCollectionStub() const selectedScripts = scripts.map((script) => script.toSelectedScript());
.withAction(new CategoryStub(1).withScripts(...scripts)); const selection = new ScriptSelectionStub()
const scriptsToSelect = scripts.map((script) => new SelectedScript(script, false)); .withSelectedScripts(selectedScripts);
const selection = new UserSelection(collection, scriptsToSelect);
const definition = new ScriptingDefinitionStub(); const definition = new ScriptingDefinitionStub();
const sut = new ApplicationCode(selection, definition); const sut = new ApplicationCode(selection, definition);
sut.changed.on((code) => { sut.changed.on((code) => {
@@ -73,17 +71,18 @@ describe('ApplicationCode', () => {
// arrange // arrange
let signaled: ICodeChangedEvent | undefined; let signaled: ICodeChangedEvent | undefined;
const scripts = [new ScriptStub('first'), new ScriptStub('second')]; const scripts = [new ScriptStub('first'), new ScriptStub('second')];
const collection = new CategoryCollectionStub() const selectedScripts = scripts.map(
.withAction(new CategoryStub(1).withScripts(...scripts)); (script) => script.toSelectedScript().withRevert(false),
const scriptsToSelect = scripts.map((script) => new SelectedScript(script, false)); );
const selection = new UserSelection(collection, scriptsToSelect); const selection = new ScriptSelectionStub()
.withSelectedScripts(selectedScripts);
const definition = new ScriptingDefinitionStub(); const definition = new ScriptingDefinitionStub();
const sut = new ApplicationCode(selection, definition); const sut = new ApplicationCode(selection, definition);
sut.changed.on((code) => { sut.changed.on((code) => {
signaled = code; signaled = code;
}); });
// act // act
selection.changed.notify(scripts.map((s) => new SelectedScript(s, false))); selection.changed.notify(selectedScripts);
// assert // assert
expectExists(signaled); expectExists(signaled);
expect(signaled.code).to.have.length.greaterThan(0); expect(signaled.code).to.have.length.greaterThan(0);
@@ -94,8 +93,8 @@ describe('ApplicationCode', () => {
it('sends scripting definition to generator', () => { it('sends scripting definition to generator', () => {
// arrange // arrange
const expectedDefinition = new ScriptingDefinitionStub(); const expectedDefinition = new ScriptingDefinitionStub();
const collection = new CategoryCollectionStub(); const selection = new ScriptSelectionStub()
const selection = new UserSelection(collection, []); .withSelectedScripts([]);
const generatorMock: IUserScriptGenerator = { const generatorMock: IUserScriptGenerator = {
buildCode: (_, definition) => { buildCode: (_, definition) => {
if (definition !== expectedDefinition) { if (definition !== expectedDefinition) {
@@ -118,13 +117,12 @@ describe('ApplicationCode', () => {
// arrange // arrange
const expectedDefinition = new ScriptingDefinitionStub(); const expectedDefinition = new ScriptingDefinitionStub();
const scripts = [new ScriptStub('first'), new ScriptStub('second')]; const scripts = [new ScriptStub('first'), new ScriptStub('second')];
const collection = new CategoryCollectionStub() const selectedScripts = scripts.map((script) => script.toSelectedScript());
.withAction(new CategoryStub(1).withScripts(...scripts)); const selection = new ScriptSelectionStub()
const scriptsToSelect = scripts.map((script) => new SelectedScript(script, false)); .withSelectedScripts(selectedScripts);
const selection = new UserSelection(collection, scriptsToSelect);
const generatorMock: IUserScriptGenerator = { const generatorMock: IUserScriptGenerator = {
buildCode: (selectedScripts) => { buildCode: (actualScripts) => {
if (JSON.stringify(selectedScripts) !== JSON.stringify(scriptsToSelect)) { if (JSON.stringify(actualScripts) !== JSON.stringify(selectedScripts)) {
throw new Error('Unexpected scripts'); throw new Error('Unexpected scripts');
} }
return { return {
@@ -136,7 +134,7 @@ describe('ApplicationCode', () => {
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new ApplicationCode(selection, expectedDefinition, generatorMock); new ApplicationCode(selection, expectedDefinition, generatorMock);
// act // act
const act = () => selection.changed.notify(scriptsToSelect); const act = () => selection.changed.notify(selectedScripts);
// assert // assert
expect(act).to.not.throw(); expect(act).to.not.throw();
}); });
@@ -144,16 +142,17 @@ describe('ApplicationCode', () => {
// arrange // arrange
let signaled: ICodeChangedEvent | undefined; let signaled: ICodeChangedEvent | undefined;
const scripts = [new ScriptStub('first'), new ScriptStub('second')]; const scripts = [new ScriptStub('first'), new ScriptStub('second')];
const collection = new CategoryCollectionStub() const selectedScripts = scripts.map(
.withAction(new CategoryStub(1).withScripts(...scripts)); (script) => script.toSelectedScript().withRevert(false),
const scriptsToSelect = scripts.map((script) => new SelectedScript(script, false)); );
const selection = new UserSelection(collection, scriptsToSelect); const selection = new ScriptSelectionStub()
.withSelectedScripts(selectedScripts);
const scriptingDefinition = new ScriptingDefinitionStub(); const scriptingDefinition = new ScriptingDefinitionStub();
const totalLines = 20; const totalLines = 20;
const expected = new Map<SelectedScript, ICodePosition>( const expected = new Map<SelectedScript, ICodePosition>(
[ [
[scriptsToSelect[0], new CodePosition(0, totalLines / 2)], [selectedScripts[0], new CodePosition(0, totalLines / 2)],
[scriptsToSelect[1], new CodePosition(totalLines / 2, totalLines)], [selectedScripts[1], new CodePosition(totalLines / 2, totalLines)],
], ],
); );
const generatorMock: IUserScriptGenerator = { const generatorMock: IUserScriptGenerator = {
@@ -169,27 +168,27 @@ describe('ApplicationCode', () => {
signaled = code; signaled = code;
}); });
// act // act
selection.changed.notify(scriptsToSelect); selection.changed.notify(selectedScripts);
// assert // assert
expectExists(signaled); expectExists(signaled);
expect(signaled.getScriptPositionInCode(scripts[0])) expect(signaled.getScriptPositionInCode(scripts[0]))
.to.deep.equal(expected.get(scriptsToSelect[0])); .to.deep.equal(expected.get(selectedScripts[0]));
expect(signaled.getScriptPositionInCode(scripts[1])) expect(signaled.getScriptPositionInCode(scripts[1]))
.to.deep.equal(expected.get(scriptsToSelect[1])); .to.deep.equal(expected.get(selectedScripts[1]));
}); });
}); });
}); });
}); });
interface IScriptGenerationParameters { interface ScriptGenerationParameters {
scripts: readonly SelectedScript[]; readonly scripts: readonly SelectedScript[];
definition: IScriptingDefinition; readonly definition: IScriptingDefinition;
} }
class UserScriptGeneratorMock implements IUserScriptGenerator { class UserScriptGeneratorMock implements IUserScriptGenerator {
private prePlanned = new Map<IScriptGenerationParameters, IUserScript>(); private prePlanned = new Map<ScriptGenerationParameters, IUserScript>();
public plan( public plan(
parameters: IScriptGenerationParameters, parameters: ScriptGenerationParameters,
result: IUserScript, result: IUserScript,
): UserScriptGeneratorMock { ): UserScriptGeneratorMock {
this.prePlanned.set(parameters, result); this.prePlanned.set(parameters, result);

View File

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

View File

@@ -1,12 +1,12 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { UserScriptGenerator } from '@/application/Context/State/Code/Generation/UserScriptGenerator'; 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 { ICodeBuilderFactory } from '@/application/Context/State/Code/Generation/ICodeBuilderFactory';
import { ICodeBuilder } from '@/application/Context/State/Code/Generation/ICodeBuilder'; import { ICodeBuilder } from '@/application/Context/State/Code/Generation/ICodeBuilder';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub'; import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { ScriptingDefinitionStub } from '@tests/unit/shared/Stubs/ScriptingDefinitionStub'; import { ScriptingDefinitionStub } from '@tests/unit/shared/Stubs/ScriptingDefinitionStub';
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests'; import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { expectExists } from '@tests/shared/Assertions/ExpectExists'; import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
describe('UserScriptGenerator', () => { describe('UserScriptGenerator', () => {
describe('scriptingDefinition', () => { describe('scriptingDefinition', () => {
@@ -94,7 +94,7 @@ describe('UserScriptGenerator', () => {
const scriptName = 'test non-revert script'; const scriptName = 'test non-revert script';
const scriptCode = 'REM nop'; const scriptCode = 'REM nop';
const script = new ScriptStub('id').withName(scriptName).withCode(scriptCode); 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(); const definition = new ScriptingDefinitionStub();
// act // act
const actual = sut.buildCode(selectedScripts, definition); const actual = sut.buildCode(selectedScripts, definition);
@@ -113,7 +113,8 @@ describe('UserScriptGenerator', () => {
const script = new ScriptStub('id') const script = new ScriptStub('id')
.withName(scriptName) .withName(scriptName)
.withRevertCode(scriptCode) .withRevertCode(scriptCode)
.toSelectedScript(true); .toSelectedScript()
.withRevert(true);
const definition = new ScriptingDefinitionStub(); const definition = new ScriptingDefinitionStub();
// act // act
const actual = sut.buildCode([script], definition); const actual = sut.buildCode([script], definition);
@@ -127,10 +128,9 @@ describe('UserScriptGenerator', () => {
const expectedError = 'Reverted script lacks revert code.'; const expectedError = 'Reverted script lacks revert code.';
const sut = new UserScriptGenerator(); const sut = new UserScriptGenerator();
const script = new ScriptStub('id') const script = new ScriptStub('id')
.toSelectedScript(true); .withRevertCode(emptyRevertCode)
// Hack until SelectedScript is interface: .toSelectedScript()
// eslint-disable-next-line @typescript-eslint/no-explicit-any .withRevert(true);
(script.script.code as any).revert = emptyRevertCode;
const definition = new ScriptingDefinitionStub(); const definition = new ScriptingDefinitionStub();
// act // act
const act = () => sut.buildCode([script], definition); const act = () => sut.buildCode([script], definition);
@@ -181,7 +181,8 @@ describe('UserScriptGenerator', () => {
const selectedScript = new ScriptStub('script-id') const selectedScript = new ScriptStub('script-id')
.withName('script') .withName('script')
.withCode(testCase.scriptCode) .withCode(testCase.scriptCode)
.toSelectedScript(false); .toSelectedScript()
.withRevert(false);
// act // act
const actual = sut.buildCode([selectedScript], definition); const actual = sut.buildCode([selectedScript], definition);
// expect // 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 { describe, it, expect } from 'vitest';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub'; 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', () => { it('id is same as script id', () => {
// arrange // arrange
const expectedId = 'scriptId'; const expectedId = 'scriptId';
const script = new ScriptStub(expectedId); const script = new ScriptStub(expectedId);
const sut = new SelectedScript(script, false); const sut = new UserSelectedScript(script, false);
// act // act
const actualId = sut.id; const actualId = sut.id;
// assert // assert
@@ -15,13 +15,13 @@ describe('SelectedScript', () => {
}); });
it('throws when revert is true for irreversible script', () => { it('throws when revert is true for irreversible script', () => {
// arrange // arrange
const expectedId = 'scriptId'; const scriptId = 'irreversibleScriptId';
const script = new ScriptStub(expectedId) const expectedError = `The script with ID '${scriptId}' is not reversible and cannot be reverted.`;
const script = new ScriptStub(scriptId)
.withRevertCode(undefined); .withRevertCode(undefined);
// act // act
// eslint-disable-next-line no-new const act = () => new UserSelectedScript(script, true);
function construct() { new SelectedScript(script, true); }
// assert // 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 { RecommendationLevel } from '@/domain/RecommendationLevel';
import { CategoryCollectionStateStub } from '@tests/unit/shared/Stubs/CategoryCollectionStateStub'; 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 { 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 { export class SelectionStateTestScenario {
public readonly all: readonly SelectedScript[]; public readonly all: readonly SelectedScript[];
@@ -28,13 +31,21 @@ export class SelectionStateTestScenario {
this.all = [...this.allStandard, ...this.allStrict, ...this.allUnrecommended]; 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); const allScripts = this.all.map((s) => s.script);
return new CategoryCollectionStateStub(allScripts) const scriptSelection = new ScriptSelectionStub()
.withSelectedScripts(selectedScripts); .withSelectedScripts(selectedScripts);
const categoryCollectionState = new CategoryCollectionStateStub(allScripts)
.withSelection(new UserSelectionStub().withScripts(scriptSelection));
return {
scriptsStub: scriptSelection,
stateStub: categoryCollectionState,
};
} }
} }
function createSelectedScripts(level?: RecommendationLevel, ...ids: string[]) { function createSelectedScripts(level?: RecommendationLevel, ...ids: string[]): SelectedScript[] {
return ids.map((id) => new SelectedScript(new ScriptStub(id).withLevel(level), false)); return ids.map((id) => new SelectedScriptStub(
new ScriptStub(id).withLevel(level),
).withRevert(false));
} }

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import { TreeNodeCheckState } from '@/presentation/components/Scripts/View/Tree/
import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub'; import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub';
import { TreeNodeStateChangedEmittedEventStub } from '@tests/unit/shared/Stubs/TreeNodeStateChangedEmittedEventStub'; import { TreeNodeStateChangedEmittedEventStub } from '@tests/unit/shared/Stubs/TreeNodeStateChangedEmittedEventStub';
import { UseUserSelectionStateStub } from '@tests/unit/shared/Stubs/UseUserSelectionStateStub'; import { UseUserSelectionStateStub } from '@tests/unit/shared/Stubs/UseUserSelectionStateStub';
import { ScriptSelectionStub } from '@tests/unit/shared/Stubs/ScriptSelectionStub';
describe('useCollectionSelectionStateUpdater', () => { describe('useCollectionSelectionStateUpdater', () => {
describe('updateNodeSelection', () => { describe('updateNodeSelection', () => {
@@ -56,9 +57,12 @@ describe('useCollectionSelectionStateUpdater', () => {
it('adds to selection if not already selected', () => { it('adds to selection if not already selected', () => {
// arrange // arrange
const { returnObject, useSelectionStateStub } = runHook(); const { returnObject, useSelectionStateStub } = runHook();
const selectionStub = new UserSelectionStub([]); const isScriptInitiallySelected = false;
selectionStub.isSelected = () => false; const scriptSelectionStub = new ScriptSelectionStub()
useSelectionStateStub.withUserSelection(selectionStub); .withIsSelectedResult(isScriptInitiallySelected);
useSelectionStateStub.withUserSelection(
new UserSelectionStub().withScripts(scriptSelectionStub),
);
const node = createTreeNodeStub({ const node = createTreeNodeStub({
isBranch: false, isBranch: false,
currentState: TreeNodeCheckState.Checked, currentState: TreeNodeCheckState.Checked,
@@ -73,14 +77,17 @@ describe('useCollectionSelectionStateUpdater', () => {
returnObject.updateNodeSelection(mockEvent); returnObject.updateNodeSelection(mockEvent);
// assert // assert
expect(useSelectionStateStub.isSelectionModified()).to.equal(true); 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', () => { it('does nothing if already selected', () => {
// arrange // arrange
const { returnObject, useSelectionStateStub } = runHook(); const { returnObject, useSelectionStateStub } = runHook();
const selectionStub = new UserSelectionStub([]); const isScriptInitiallySelected = true;
selectionStub.isSelected = () => true; const scriptSelectionStub = new ScriptSelectionStub()
useSelectionStateStub.withUserSelection(selectionStub); .withIsSelectedResult(isScriptInitiallySelected);
useSelectionStateStub.withUserSelection(
new UserSelectionStub().withScripts(scriptSelectionStub),
);
const mockEvent = new TreeNodeStateChangedEmittedEventStub() const mockEvent = new TreeNodeStateChangedEmittedEventStub()
.withNode( .withNode(
createTreeNodeStub({ createTreeNodeStub({
@@ -102,9 +109,12 @@ describe('useCollectionSelectionStateUpdater', () => {
it('removes from selection if already selected', () => { it('removes from selection if already selected', () => {
// arrange // arrange
const { returnObject, useSelectionStateStub } = runHook(); const { returnObject, useSelectionStateStub } = runHook();
const selectionStub = new UserSelectionStub([]); const isScriptInitiallySelected = true;
selectionStub.isSelected = () => true; const scriptSelectionStub = new ScriptSelectionStub()
useSelectionStateStub.withUserSelection(selectionStub); .withIsSelectedResult(isScriptInitiallySelected);
useSelectionStateStub.withUserSelection(
new UserSelectionStub().withScripts(scriptSelectionStub),
);
const node = createTreeNodeStub({ const node = createTreeNodeStub({
isBranch: false, isBranch: false,
currentState: TreeNodeCheckState.Unchecked, currentState: TreeNodeCheckState.Unchecked,
@@ -119,14 +129,17 @@ describe('useCollectionSelectionStateUpdater', () => {
returnObject.updateNodeSelection(mockEvent); returnObject.updateNodeSelection(mockEvent);
// assert // assert
expect(useSelectionStateStub.isSelectionModified()).to.equal(true); 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', () => { it('does nothing if not already selected', () => {
// arrange // arrange
const { returnObject, useSelectionStateStub } = runHook(); const { returnObject, useSelectionStateStub } = runHook();
const selectionStub = new UserSelectionStub([]); const isScriptInitiallySelected = false;
selectionStub.isSelected = () => false; const scriptSelectionStub = new ScriptSelectionStub()
useSelectionStateStub.withUserSelection(selectionStub); .withIsSelectedResult(isScriptInitiallySelected);
useSelectionStateStub.withUserSelection(
new UserSelectionStub().withScripts(scriptSelectionStub),
);
const mockEvent = new TreeNodeStateChangedEmittedEventStub() const mockEvent = new TreeNodeStateChangedEmittedEventStub()
.withNode( .withNode(
createTreeNodeStub({ 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 { getScriptNodeId } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter';
import { IScript } from '@/domain/IScript'; import { IScript } from '@/domain/IScript';
import { UseUserSelectionStateStub } from '@tests/unit/shared/Stubs/UseUserSelectionStateStub'; import { UseUserSelectionStateStub } from '@tests/unit/shared/Stubs/UseUserSelectionStateStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
describe('useSelectedScriptNodeIds', () => { describe('useSelectedScriptNodeIds', () => {
it('returns an empty array when no scripts are selected', () => { it('returns an empty array when no scripts are selected', () => {
@@ -19,8 +20,8 @@ describe('useSelectedScriptNodeIds', () => {
it('immediately', () => { it('immediately', () => {
// arrange // arrange
const selectedScripts = [ const selectedScripts = [
new SelectedScriptStub('id-1'), new SelectedScriptStub(new ScriptStub('id-1')),
new SelectedScriptStub('id-2'), new SelectedScriptStub(new ScriptStub('id-2')),
]; ];
const parsedNodeIds = new Map<IScript, string>([ const parsedNodeIds = new Map<IScript, string>([
[selectedScripts[0].script, 'expected-id-1'], [selectedScripts[0].script, 'expected-id-1'],
@@ -43,8 +44,8 @@ describe('useSelectedScriptNodeIds', () => {
// arrange // arrange
const initialScripts = []; const initialScripts = [];
const changedScripts = [ const changedScripts = [
new SelectedScriptStub('id-1'), new SelectedScriptStub(new ScriptStub('id-1')),
new SelectedScriptStub('id-2'), new SelectedScriptStub(new ScriptStub('id-2')),
]; ];
const parsedNodeIds = new Map<IScript, string>([ const parsedNodeIds = new Map<IScript, string>([
[changedScripts[0].script, 'expected-id-1'], [changedScripts[0].script, 'expected-id-1'],

View File

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

View File

@@ -33,6 +33,13 @@ export class CategoryCollectionStub implements ICategoryCollection {
return this; return this;
} }
public withActions(...actions: readonly ICategory[]): this {
for (const action of actions) {
this.withAction(action);
}
return this;
}
public withOs(os: OperatingSystem): this { public withOs(os: OperatingSystem): this {
this.os = os; this.os = os;
return this; 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>(); public readonly docs = new Array<string>();
private allScriptsRecursively: (readonly IScript[]) | undefined;
public constructor(id: number) { public constructor(id: number) {
super(id); super(id);
} }
@@ -21,13 +23,16 @@ export class CategoryStub extends BaseEntity<number> implements ICategory {
} }
public getAllScriptsRecursively(): readonly IScript[] { public getAllScriptsRecursively(): readonly IScript[] {
return [ if (this.allScriptsRecursively === undefined) {
...this.scripts, return [
...this.subCategories.flatMap((c) => c.getAllScriptsRecursively()), ...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( return this.withScripts(
...scriptIds.map((id) => new ScriptStub(id)), ...scriptIds.map((id) => new ScriptStub(id)),
); );
@@ -40,6 +45,15 @@ export class CategoryStub extends BaseEntity<number> implements ICategory {
return this; 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 { public withMandatoryScripts(): this {
return this return this
.withScript(new ScriptStub(`[${CategoryStub.name}] script-1`).withLevel(RecommendationLevel.Standard)) .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 { BaseEntity } from '@/infrastructure/Entity/BaseEntity';
import { IScript } from '@/domain/IScript'; import { IScript } from '@/domain/IScript';
import { RecommendationLevel } from '@/domain/RecommendationLevel'; import { RecommendationLevel } from '@/domain/RecommendationLevel';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IScriptCode } from '@/domain/IScriptCode'; import { IScriptCode } from '@/domain/IScriptCode';
import { SelectedScriptStub } from './SelectedScriptStub';
export class ScriptStub extends BaseEntity<string> implements IScript { export class ScriptStub extends BaseEntity<string> implements IScript {
public name = `name${this.id}`; public name = `name${this.id}`;
@@ -50,7 +50,7 @@ export class ScriptStub extends BaseEntity<string> implements IScript {
return this; return this;
} }
public toSelectedScript(isReverted = false): SelectedScript { public toSelectedScript(): SelectedScriptStub {
return new SelectedScript(this, isReverted); return new SelectedScriptStub(this);
} }
} }

View File

@@ -1,8 +1,26 @@
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript'; import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { ScriptStub } from './ScriptStub'; import { IScript } from '@/domain/IScript';
export class SelectedScriptStub extends SelectedScript { export class SelectedScriptStub implements SelectedScript {
constructor(id: string, revert = false) { public readonly script: IScript;
super(new ScriptStub(id), revert);
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>]: { [K in FunctionKeys<T>]: {
readonly methodName: K; readonly methodName: K;
readonly args: T[K] extends (...args: infer A) => unknown ? A : never; 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 { shallowRef } from 'vue';
import type { SelectionModifier, useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState'; import type { SelectionModifier, useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection'; import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript'; import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls'; import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
import { UserSelectionStub } from './UserSelectionStub'; import { UserSelectionStub } from './UserSelectionStub';
import { ScriptSelectionStub } from './ScriptSelectionStub';
export class UseUserSelectionStateStub export class UseUserSelectionStateStub
extends StubWithObservableMethodCalls<ReturnType<typeof useUserSelectionState>> { extends StubWithObservableMethodCalls<ReturnType<typeof useUserSelectionState>> {
private readonly currentSelection = shallowRef<IUserSelection>( private readonly currentSelection = shallowRef<UserSelection>(
new UserSelectionStub([]), new UserSelectionStub(),
); );
private modifyCurrentSelection(mutator: SelectionModifier) { 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; this.currentSelection.value = userSelection;
return this; return this;
} }
public withSelectedScripts(selectedScripts: readonly SelectedScript[]): this { public withSelectedScripts(selectedScripts: readonly SelectedScript[]): this {
return this.withUserSelection( return this.withUserSelection(
new UserSelectionStub(selectedScripts.map((s) => s.script)) new UserSelectionStub()
.withSelectedScripts(selectedScripts), .withScripts(
new ScriptSelectionStub().withSelectedScripts(selectedScripts),
),
); );
} }
public get selection(): IUserSelection { public get selection(): UserSelection {
return this.currentSelection.value; return this.currentSelection.value;
} }

View File

@@ -1,91 +1,21 @@
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection'; import { CategorySelection } from '@/application/Context/State/Selection/Category/CategorySelection';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript'; import { ScriptSelection } from '@/application/Context/State/Selection/Script/ScriptSelection';
import { IScript } from '@/domain/IScript'; import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls'; import { CategorySelectionStub } from './CategorySelectionStub';
import { EventSourceStub } from './EventSourceStub'; import { ScriptSelectionStub } from './ScriptSelectionStub';
export class UserSelectionStub export class UserSelectionStub implements UserSelection {
extends StubWithObservableMethodCalls<IUserSelection> public categories: CategorySelection = new CategorySelectionStub();
implements IUserSelection {
public readonly changed = new EventSourceStub<readonly SelectedScript[]>();
public selectedScripts: readonly SelectedScript[] = []; public scripts: ScriptSelection = new ScriptSelectionStub();
constructor(private readonly allScripts: readonly IScript[]) { public withCategories(categories: CategorySelection): this {
super(); this.categories = categories;
}
public withSelectedScripts(selectedScripts: readonly SelectedScript[]): this {
this.selectedScripts = selectedScripts;
return this; return this;
} }
public triggerSelectionChangedEvent(scripts: readonly SelectedScript[]): this { public withScripts(scripts: ScriptSelection): this {
this.changed.notify(scripts); this.scripts = scripts;
return this; 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 = [];
}
} }