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:
@@ -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.
|
||||
- [`./tests/integration/`](./../tests/integration/): Contains integration test files.
|
||||
- [`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.
|
||||
- [`/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.
|
||||
- *(git ignored)* `/videos`: Asset folder for videos 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.
|
||||
|
||||
27
src/application/Common/Timing/BatchedDebounce.ts
Normal file
27
src/application/Common/Timing/BatchedDebounce.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
7
src/application/Common/Timing/PlatformTimer.ts
Normal file
7
src/application/Common/Timing/PlatformTimer.ts
Normal 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(),
|
||||
};
|
||||
@@ -1,40 +1,24 @@
|
||||
import { Timer, TimeoutType } from './Timer';
|
||||
import { PlatformTimer } from './PlatformTimer';
|
||||
|
||||
export type CallbackType = (..._: unknown[]) => void;
|
||||
|
||||
export function throttle(
|
||||
callback: CallbackType,
|
||||
waitInMs: number,
|
||||
timer: ITimer = NodeTimer,
|
||||
timer: Timer = PlatformTimer,
|
||||
): CallbackType {
|
||||
const throttler = new Throttler(timer, waitInMs, callback);
|
||||
return (...args: unknown[]) => throttler.invoke(...args);
|
||||
}
|
||||
|
||||
// Allows aligning with both NodeJs (NodeJs.Timeout) and Window type (number)
|
||||
export type Timeout = ReturnType<typeof setTimeout>;
|
||||
|
||||
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;
|
||||
class Throttler {
|
||||
private queuedExecutionId: TimeoutType | undefined;
|
||||
|
||||
private previouslyRun: number;
|
||||
|
||||
constructor(
|
||||
private readonly timer: ITimer,
|
||||
private readonly timer: Timer,
|
||||
private readonly waitInMs: number,
|
||||
private readonly callback: CallbackType,
|
||||
) {
|
||||
8
src/application/Common/Timing/Timer.ts
Normal file
8
src/application/Common/Timing/Timer.ts
Normal 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;
|
||||
}
|
||||
@@ -4,23 +4,48 @@ import { UserFilter } from './Filter/UserFilter';
|
||||
import { IUserFilter } from './Filter/IUserFilter';
|
||||
import { ApplicationCode } from './Code/ApplicationCode';
|
||||
import { UserSelection } from './Selection/UserSelection';
|
||||
import { IUserSelection } from './Selection/IUserSelection';
|
||||
import { ICategoryCollectionState } from './ICategoryCollectionState';
|
||||
import { IApplicationCode } from './Code/IApplicationCode';
|
||||
import { UserSelectionFacade } from './Selection/UserSelectionFacade';
|
||||
|
||||
export class CategoryCollectionState implements ICategoryCollectionState {
|
||||
public readonly os: OperatingSystem;
|
||||
|
||||
public readonly code: IApplicationCode;
|
||||
|
||||
public readonly selection: IUserSelection;
|
||||
public readonly selection: UserSelection;
|
||||
|
||||
public readonly filter: IUserFilter;
|
||||
|
||||
public constructor(readonly collection: ICategoryCollection) {
|
||||
this.selection = new UserSelection(collection, []);
|
||||
this.code = new ApplicationCode(this.selection, collection.scripting);
|
||||
this.filter = new UserFilter(collection);
|
||||
public constructor(
|
||||
public readonly collection: ICategoryCollection,
|
||||
selectionFactory = DefaultSelectionFactory,
|
||||
codeFactory = DefaultCodeFactory,
|
||||
filterFactory = DefaultFilterFactory,
|
||||
) {
|
||||
this.selection = selectionFactory(collection, []);
|
||||
this.code = codeFactory(this.selection.scripts, collection.scripting);
|
||||
this.filter = filterFactory(collection);
|
||||
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);
|
||||
|
||||
@@ -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 { 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 { CodePosition } from './Position/CodePosition';
|
||||
import { ICodeChangedEvent } from './Event/ICodeChangedEvent';
|
||||
@@ -17,12 +17,12 @@ export class ApplicationCode implements IApplicationCode {
|
||||
private scriptPositions = new Map<SelectedScript, CodePosition>();
|
||||
|
||||
constructor(
|
||||
userSelection: IReadOnlyUserSelection,
|
||||
selection: ReadonlyScriptSelection,
|
||||
private readonly scriptingDefinition: IScriptingDefinition,
|
||||
private readonly generator: IUserScriptGenerator = new UserScriptGenerator(),
|
||||
) {
|
||||
this.setCode(userSelection.selectedScripts);
|
||||
userSelection.changed.on((scripts) => {
|
||||
this.setCode(selection.selectedScripts);
|
||||
selection.changed.on((scripts) => {
|
||||
this.setCode(scripts);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { IScript } from '@/domain/IScript';
|
||||
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';
|
||||
|
||||
export class CodeChangedEvent implements ICodeChangedEvent {
|
||||
@@ -36,7 +36,14 @@ export class CodeChangedEvent implements ICodeChangedEvent {
|
||||
}
|
||||
|
||||
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) {
|
||||
throw new Error('Unknown script: Position could not be found for the script');
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePo
|
||||
|
||||
export interface ICodeChangedEvent {
|
||||
readonly code: string;
|
||||
addedScripts: ReadonlyArray<IScript>;
|
||||
removedScripts: ReadonlyArray<IScript>;
|
||||
changedScripts: ReadonlyArray<IScript>;
|
||||
readonly addedScripts: ReadonlyArray<IScript>;
|
||||
readonly removedScripts: ReadonlyArray<IScript>;
|
||||
readonly changedScripts: ReadonlyArray<IScript>;
|
||||
isEmpty(): boolean;
|
||||
getScriptPositionInCode(script: IScript): ICodePosition;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
|
||||
|
||||
export interface IUserScript {
|
||||
code: string;
|
||||
scriptPositions: Map<SelectedScript, ICodePosition>;
|
||||
readonly code: string;
|
||||
readonly scriptPositions: Map<SelectedScript, ICodePosition>;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
|
||||
import { IUserScript } from './IUserScript';
|
||||
|
||||
export interface IUserScriptGenerator {
|
||||
buildCode(
|
||||
selectedScripts: ReadonlyArray<SelectedScript>,
|
||||
scriptingDefinition: IScriptingDefinition): IUserScript;
|
||||
scriptingDefinition: IScriptingDefinition,
|
||||
): IUserScript;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
|
||||
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
|
||||
import { CodePosition } from '../Position/CodePosition';
|
||||
import { IUserScriptGenerator } from './IUserScriptGenerator';
|
||||
import { IUserScript } from './IUserScript';
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { ICategoryCollection } from '@/domain/ICategoryCollection';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { IReadOnlyUserFilter, IUserFilter } from './Filter/IUserFilter';
|
||||
import { IReadOnlyUserSelection, IUserSelection } from './Selection/IUserSelection';
|
||||
import { ReadonlyUserSelection, UserSelection } from './Selection/UserSelection';
|
||||
import { IApplicationCode } from './Code/IApplicationCode';
|
||||
|
||||
export interface IReadOnlyCategoryCollectionState {
|
||||
readonly code: IApplicationCode;
|
||||
readonly os: OperatingSystem;
|
||||
readonly filter: IReadOnlyUserFilter;
|
||||
readonly selection: IReadOnlyUserSelection;
|
||||
readonly selection: ReadonlyUserSelection;
|
||||
readonly collection: ICategoryCollection;
|
||||
}
|
||||
|
||||
export interface ICategoryCollectionState extends IReadOnlyCategoryCollectionState {
|
||||
readonly filter: IUserFilter;
|
||||
readonly selection: IUserSelection;
|
||||
readonly selection: UserSelection;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import { BaseEntity } from '@/infrastructure/Entity/BaseEntity';
|
||||
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(
|
||||
public readonly script: IScript,
|
||||
public readonly revert: boolean,
|
||||
) {
|
||||
super(script.id);
|
||||
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.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,164 +1,12 @@
|
||||
import { InMemoryRepository } from '@/infrastructure/Repository/InMemoryRepository';
|
||||
import { IScript } from '@/domain/IScript';
|
||||
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';
|
||||
import { CategorySelection, ReadonlyCategorySelection } from './Category/CategorySelection';
|
||||
import { ReadonlyScriptSelection, ScriptSelection } from './Script/ScriptSelection';
|
||||
|
||||
export class UserSelection implements IUserSelection {
|
||||
public readonly changed = new EventSource<ReadonlyArray<SelectedScript>>();
|
||||
|
||||
private readonly scripts: IRepository<string, SelectedScript>;
|
||||
|
||||
constructor(
|
||||
private readonly collection: ICategoryCollection,
|
||||
selectedScripts: ReadonlyArray<SelectedScript>,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
export interface ReadonlyUserSelection {
|
||||
readonly categories: ReadonlyCategorySelection;
|
||||
readonly scripts: ReadonlyScriptSelection;
|
||||
}
|
||||
|
||||
export interface UserSelection extends ReadonlyUserSelection {
|
||||
readonly categories: CategorySelection;
|
||||
readonly scripts: ScriptSelection;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
17
src/application/Repository/Repository.ts
Normal file
17
src/application/Repository/Repository.ts
Normal 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> { }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { IEntity } from '../Entity/IEntity';
|
||||
import { IRepository } from './IRepository';
|
||||
import { Repository } from '../../application/Repository/Repository';
|
||||
|
||||
export class InMemoryRepository<TKey, TEntity extends IEntity<TKey>>
|
||||
implements IRepository<TKey, TEntity> {
|
||||
implements Repository<TKey, TEntity> {
|
||||
private readonly items: TEntity[];
|
||||
|
||||
constructor(items?: TEntity[]) {
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
v-non-collapsing
|
||||
@size-changed="sizeChanged()"
|
||||
>
|
||||
<!-- `data-test-highlighted-range` is a test hook for assessing highlighted text range -->
|
||||
<div
|
||||
:id="editorId"
|
||||
:data-test-highlighted-range="highlightedRange"
|
||||
class="code-area"
|
||||
/>
|
||||
</SizeObserver>
|
||||
@@ -12,7 +14,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
defineComponent, onUnmounted, onMounted,
|
||||
defineComponent, onUnmounted, onMounted, ref,
|
||||
} from 'vue';
|
||||
import { injectKey } from '@/presentation/injectionSymbols';
|
||||
import { ICodeChangedEvent } from '@/application/Context/State/Code/Event/ICodeChangedEvent';
|
||||
@@ -42,6 +44,8 @@ export default defineComponent({
|
||||
const { events } = injectKey((keys) => keys.useAutoUnsubscribedEvents);
|
||||
|
||||
const editorId = 'codeEditor';
|
||||
const highlightedRange = ref(0);
|
||||
|
||||
let editor: ace.Ace.Editor | undefined;
|
||||
let currentMarkerId: number | undefined;
|
||||
|
||||
@@ -99,6 +103,7 @@ export default defineComponent({
|
||||
}
|
||||
editor?.session.removeMarker(currentMarkerId);
|
||||
currentMarkerId = undefined;
|
||||
highlightedRange.value = 0;
|
||||
}
|
||||
|
||||
function reactToChanges(event: ICodeChangedEvent, scripts: ReadonlyArray<IScript>) {
|
||||
@@ -121,6 +126,7 @@ export default defineComponent({
|
||||
'code-area__highlight',
|
||||
'fullLine',
|
||||
);
|
||||
highlightedRange.value = endRow - startRow;
|
||||
}
|
||||
|
||||
function scrollToLine(row: number) {
|
||||
@@ -133,6 +139,7 @@ export default defineComponent({
|
||||
|
||||
return {
|
||||
editorId,
|
||||
highlightedRange,
|
||||
sizeChanged,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { IScript } from '@/domain/IScript';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import { RecommendationLevel } from '@/domain/RecommendationLevel';
|
||||
import { scrambledEqual } from '@/application/Common/Array';
|
||||
import { IReadOnlyUserSelection, IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
|
||||
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 {
|
||||
Standard,
|
||||
@@ -34,12 +34,12 @@ export function getCurrentSelectionType(context: SelectionCheckContext): Selecti
|
||||
}
|
||||
|
||||
export interface SelectionCheckContext {
|
||||
readonly selection: IReadOnlyUserSelection;
|
||||
readonly selection: ReadonlyScriptSelection;
|
||||
readonly collection: ICategoryCollection;
|
||||
}
|
||||
|
||||
export interface SelectionMutationContext {
|
||||
readonly selection: IUserSelection,
|
||||
readonly selection: ScriptSelection,
|
||||
readonly collection: ICategoryCollection,
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ export default defineComponent({
|
||||
|
||||
const currentSelectionType = computed<SelectionType>({
|
||||
get: () => getCurrentSelectionType({
|
||||
selection: currentSelection.value,
|
||||
selection: currentSelection.value.scripts,
|
||||
collection: currentCollection.value,
|
||||
}),
|
||||
set: (type: SelectionType) => {
|
||||
@@ -105,7 +105,7 @@ export default defineComponent({
|
||||
}
|
||||
modifyCurrentSelection((mutableSelection) => {
|
||||
setCurrentSelectionType(type, {
|
||||
selection: mutableSelection,
|
||||
selection: mutableSelection.scripts,
|
||||
collection: currentCollection.value,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,11 +38,11 @@ export default defineComponent({
|
||||
);
|
||||
|
||||
const isAnyChildSelected = computed<boolean>(
|
||||
() => currentSelection.value.isAnySelected(currentCategory.value),
|
||||
() => currentSelection.value.categories.isAnyScriptSelected(currentCategory.value),
|
||||
);
|
||||
|
||||
const areAllChildrenSelected = computed<boolean>(
|
||||
() => currentSelection.value.areAllSelected(currentCategory.value),
|
||||
() => currentSelection.value.categories.areAllScriptsSelected(currentCategory.value),
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -41,7 +41,7 @@ export default defineComponent({
|
||||
|
||||
const isReverted = computed<boolean>({
|
||||
get() {
|
||||
const { selectedScripts } = currentSelection.value;
|
||||
const { selectedScripts } = currentSelection.value.scripts;
|
||||
return revertHandler.value.getState(selectedScripts);
|
||||
},
|
||||
set: (value: boolean) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
|
||||
import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
|
||||
import { ICategoryCollection } from '@/domain/ICategoryCollection';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
|
||||
import { getCategoryId } from '../../TreeViewAdapter/CategoryNodeMetadataConverter';
|
||||
import { IReverter } from './IReverter';
|
||||
import { ScriptReverter } from './ScriptReverter';
|
||||
@@ -19,8 +19,16 @@ export class CategoryReverter implements IReverter {
|
||||
return this.scriptReverters.every((script) => script.getState(selectedScripts));
|
||||
}
|
||||
|
||||
public selectWithRevertState(newState: boolean, selection: IUserSelection): void {
|
||||
selection.addOrUpdateAllInCategory(this.categoryId, newState);
|
||||
public selectWithRevertState(newState: boolean, selection: UserSelection): void {
|
||||
selection.categories.processChanges({
|
||||
changes: [{
|
||||
categoryId: this.categoryId,
|
||||
newStatus: {
|
||||
isSelected: true,
|
||||
isReverted: newState,
|
||||
},
|
||||
}],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
|
||||
|
||||
export interface IReverter {
|
||||
getState(selectedScripts: ReadonlyArray<SelectedScript>): boolean;
|
||||
selectWithRevertState(newState: boolean, selection: IUserSelection): void;
|
||||
selectWithRevertState(newState: boolean, selection: UserSelection): void;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
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 { getScriptId } from '../../TreeViewAdapter/CategoryNodeMetadataConverter';
|
||||
import { IReverter } from './IReverter';
|
||||
|
||||
@@ -18,7 +18,15 @@ export class ScriptReverter implements IReverter {
|
||||
return selectedScript.revert;
|
||||
}
|
||||
|
||||
public selectWithRevertState(newState: boolean, selection: IUserSelection): void {
|
||||
selection.addOrUpdateSelectedScript(this.scriptId, newState);
|
||||
public selectWithRevertState(newState: boolean, selection: UserSelection): void {
|
||||
selection.scripts.processChanges({
|
||||
changes: [{
|
||||
scriptId: this.scriptId,
|
||||
newStatus: {
|
||||
isSelected: true,
|
||||
isReverted: newState,
|
||||
},
|
||||
}],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,19 +16,38 @@ export function useCollectionSelectionStateUpdater(
|
||||
return;
|
||||
}
|
||||
if (node.state.current.checkState === TreeNodeCheckState.Checked) {
|
||||
if (currentSelection.value.isSelected(node.id)) {
|
||||
if (currentSelection.value.scripts.isSelected(node.id)) {
|
||||
return;
|
||||
}
|
||||
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 (!currentSelection.value.isSelected(node.id)) {
|
||||
if (!currentSelection.value.scripts.isSelected(node.id)) {
|
||||
return;
|
||||
}
|
||||
modifyCurrentSelection((selection) => {
|
||||
selection.removeSelectedScript(node.id);
|
||||
selection.scripts.processChanges({
|
||||
changes: [
|
||||
{
|
||||
scriptId: node.id,
|
||||
newStatus: {
|
||||
isSelected: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export function useSelectedScriptNodeIds(
|
||||
const selectedNodeIds = computed<readonly string[]>(() => {
|
||||
return currentSelection
|
||||
.value
|
||||
.scripts
|
||||
.selectedScripts
|
||||
.map((selected) => scriptNodeIdParser(selected.script));
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { useCollectionState } from './UseCollectionState';
|
||||
|
||||
@@ -10,12 +10,12 @@ export function useUserSelectionState(
|
||||
const { events } = autoUnsubscribedEvents;
|
||||
const { onStateChange, modifyCurrentState, currentState } = collectionState;
|
||||
|
||||
const currentSelection = shallowRef<IReadOnlyUserSelection>(currentState.value.selection);
|
||||
const currentSelection = shallowRef<ReadonlyUserSelection>(currentState.value.selection);
|
||||
|
||||
onStateChange((state) => {
|
||||
updateSelection(state.selection);
|
||||
events.unsubscribeAllAndRegister([
|
||||
state.selection.changed.on(() => {
|
||||
state.selection.scripts.changed.on(() => {
|
||||
updateSelection(state.selection);
|
||||
}),
|
||||
]);
|
||||
@@ -27,7 +27,7 @@ export function useUserSelectionState(
|
||||
});
|
||||
}
|
||||
|
||||
function updateSelection(newSelection: IReadOnlyUserSelection) {
|
||||
function updateSelection(newSelection: ReadonlyUserSelection) {
|
||||
if (currentSelection.value === newSelection) {
|
||||
// Do not trust Vue tracking, the changed selection object
|
||||
// reference may stay same for same collection.
|
||||
@@ -44,5 +44,5 @@ export function useUserSelectionState(
|
||||
}
|
||||
|
||||
export type SelectionModifier = (
|
||||
state: IUserSelection,
|
||||
state: UserSelection,
|
||||
) => void;
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
defineComponent, shallowRef, onMounted, onBeforeUnmount, watch,
|
||||
} from 'vue';
|
||||
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({
|
||||
emits: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// eslint-disable-next-line max-classes-per-file
|
||||
import { waitForHeaderBrandTitle } from './shared/ApplicationLoad';
|
||||
import { getHeaderBrandTitle } from './support/interactions/header';
|
||||
|
||||
interface Stoppable {
|
||||
stop(): void;
|
||||
@@ -175,7 +175,7 @@ enum ApplicationLoadStep {
|
||||
const checkpoints: Record<ApplicationLoadStep, () => void> = {
|
||||
[ApplicationLoadStep.IndexHtmlLoaded]: () => cy.get('#app').should('be.visible'),
|
||||
[ApplicationLoadStep.AppVueLoaded]: () => cy.get('.app__wrapper').should('be.visible'),
|
||||
[ApplicationLoadStep.HeaderBrandTitleLoaded]: () => waitForHeaderBrandTitle(),
|
||||
[ApplicationLoadStep.HeaderBrandTitleLoaded]: () => getHeaderBrandTitle(),
|
||||
};
|
||||
|
||||
class ContinuousRunner implements Stoppable {
|
||||
|
||||
47
tests/e2e/code-highlighting.cy.ts
Normal file
47
tests/e2e/code-highlighting.cy.ts
Normal 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));
|
||||
});
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { waitForHeaderBrandTitle } from './shared/ApplicationLoad';
|
||||
import { getHeaderBrandTitle } from './support/interactions/header';
|
||||
|
||||
describe('application is initialized as expected', () => {
|
||||
it('loads title as expected', () => {
|
||||
// act
|
||||
cy.visit('/');
|
||||
// assert
|
||||
waitForHeaderBrandTitle();
|
||||
getHeaderBrandTitle();
|
||||
});
|
||||
it('there are no console.error output', () => {
|
||||
// act
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
import { openCard } from './support/interactions/card';
|
||||
|
||||
describe('revert toggle', () => {
|
||||
context('toggle switch', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/');
|
||||
cy.get('.card')
|
||||
.eq(1) // to get 2nd element, first is often cleanup that may lack revert button
|
||||
.click(); // open the card card
|
||||
openCard({
|
||||
cardIndex: 1, // first is often cleanup that may lack revert button
|
||||
});
|
||||
cy.get('.toggle-switch')
|
||||
.first()
|
||||
.as('toggleSwitch');
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export function waitForHeaderBrandTitle() {
|
||||
cy.contains('h1', 'privacy.sexy');
|
||||
}
|
||||
7
tests/e2e/support/interactions/card.ts
Normal file
7
tests/e2e/support/interactions/card.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function openCard(options: {
|
||||
readonly cardIndex: number;
|
||||
}) {
|
||||
cy.get('.card')
|
||||
.eq(options.cardIndex)
|
||||
.click();
|
||||
}
|
||||
3
tests/e2e/support/interactions/header.ts
Normal file
3
tests/e2e/support/interactions/header.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function getHeaderBrandTitle() {
|
||||
cy.contains('h1', 'privacy.sexy');
|
||||
}
|
||||
144
tests/unit/application/Common/Timing/BatchedDebounce.spec.ts
Normal file
144
tests/unit/application/Common/Timing/BatchedDebounce.spec.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
78
tests/unit/application/Common/Timing/PlatformTimer.spec.ts
Normal file
78
tests/unit/application/Common/Timing/PlatformTimer.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { throttle, ITimer, Timeout } from '@/presentation/components/Shared/Throttle';
|
||||
import { EventSource } from '@/infrastructure/Events/EventSource';
|
||||
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
|
||||
import { createMockTimeout } from '@tests/unit/shared/Stubs/TimeoutStub';
|
||||
import { TimerStub } from '@tests/unit/shared/Stubs/TimerStub';
|
||||
import { throttle } from '@/application/Common/Timing/Throttle';
|
||||
|
||||
describe('throttle', () => {
|
||||
describe('validates parameters', () => {
|
||||
@@ -34,7 +32,7 @@ describe('throttle', () => {
|
||||
});
|
||||
it('should call the callback immediately', () => {
|
||||
// arrange
|
||||
const timer = new TimerMock();
|
||||
const timer = new TimerStub();
|
||||
let totalRuns = 0;
|
||||
const callback = () => totalRuns++;
|
||||
const throttleFunc = throttle(callback, 500, timer);
|
||||
@@ -45,7 +43,7 @@ describe('throttle', () => {
|
||||
});
|
||||
it('should call the callback again after the timeout', () => {
|
||||
// arrange
|
||||
const timer = new TimerMock();
|
||||
const timer = new TimerStub();
|
||||
let totalRuns = 0;
|
||||
const callback = () => totalRuns++;
|
||||
const waitInMs = 500;
|
||||
@@ -60,7 +58,7 @@ describe('throttle', () => {
|
||||
});
|
||||
it('should call the callback at most once at given time', () => {
|
||||
// arrange
|
||||
const timer = new TimerMock();
|
||||
const timer = new TimerStub();
|
||||
let totalRuns = 0;
|
||||
const callback = () => totalRuns++;
|
||||
const waitInMs = 500;
|
||||
@@ -77,7 +75,7 @@ describe('throttle', () => {
|
||||
});
|
||||
it('should call the callback as long as delay is waited', () => {
|
||||
// arrange
|
||||
const timer = new TimerMock();
|
||||
const timer = new TimerStub();
|
||||
let totalRuns = 0;
|
||||
const callback = () => totalRuns++;
|
||||
const waitInMs = 500;
|
||||
@@ -93,7 +91,7 @@ describe('throttle', () => {
|
||||
});
|
||||
it('should call arguments as expected', () => {
|
||||
// arrange
|
||||
const timer = new TimerMock();
|
||||
const timer = new TimerStub();
|
||||
const expected = [1, 2, 3];
|
||||
const actual = new Array<number>();
|
||||
const callback = (arg: number) => { actual.push(arg); };
|
||||
@@ -108,41 +106,3 @@ describe('throttle', () => {
|
||||
expect(expected).to.deep.equal(actual);
|
||||
});
|
||||
});
|
||||
|
||||
class TimerMock implements ITimer {
|
||||
private timeChanged = new EventSource<number>();
|
||||
|
||||
private subscriptions = new Array<IEventSubscription>();
|
||||
|
||||
private currentTime = 0;
|
||||
|
||||
public setTimeout(callback: () => void, ms: number): Timeout {
|
||||
const runTime = this.currentTime + ms;
|
||||
const subscription = this.timeChanged.on((time) => {
|
||||
if (time >= runTime) {
|
||||
callback();
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
});
|
||||
this.subscriptions.push(subscription);
|
||||
const id = this.subscriptions.length - 1;
|
||||
return createMockTimeout(id);
|
||||
}
|
||||
|
||||
public clearTimeout(timeoutId: Timeout): void {
|
||||
this.subscriptions[+timeoutId].unsubscribe();
|
||||
}
|
||||
|
||||
public dateNow(): number {
|
||||
return this.currentTime;
|
||||
}
|
||||
|
||||
public tickNext(ms: number): void {
|
||||
this.setCurrentTime(this.currentTime + ms);
|
||||
}
|
||||
|
||||
public setCurrentTime(ms: number): void {
|
||||
this.currentTime = ms;
|
||||
this.timeChanged.notify(this.currentTime);
|
||||
}
|
||||
}
|
||||
@@ -1,106 +1,167 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
|
||||
import { ApplicationCode } from '@/application/Context/State/Code/ApplicationCode';
|
||||
import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub';
|
||||
import { CategoryCollectionState } from '@/application/Context/State/CategoryCollectionState';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { IScript } from '@/domain/IScript';
|
||||
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
|
||||
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
|
||||
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
|
||||
import { ScriptingDefinitionStub } from '@tests/unit/shared/Stubs/ScriptingDefinitionStub';
|
||||
import { ICategoryCollection } from '@/domain/ICategoryCollection';
|
||||
import { ApplicationCodeStub } from '@tests/unit/shared/Stubs/ApplicationCodeStub';
|
||||
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
import { ReadonlyScriptSelection } from '@/application/Context/State/Selection/Script/ScriptSelection';
|
||||
import { ScriptSelectionStub } from '@tests/unit/shared/Stubs/ScriptSelectionStub';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
|
||||
import { UserFilterStub } from '@tests/unit/shared/Stubs/UserFilterStub';
|
||||
import type { CodeFactory, FilterFactory, SelectionFactory } from '@/application/Context/State/CategoryCollectionState';
|
||||
|
||||
describe('CategoryCollectionState', () => {
|
||||
describe('code', () => {
|
||||
it('initialized with empty code', () => {
|
||||
// arrange
|
||||
const collection = new CategoryCollectionStub();
|
||||
const sut = new CategoryCollectionState(collection);
|
||||
// act
|
||||
const code = sut.code.current;
|
||||
// assert
|
||||
expect(!code);
|
||||
});
|
||||
it('reacts to selection changes as expected', () => {
|
||||
it('uses the correct scripting definition', () => {
|
||||
// arrange
|
||||
const expectedScripting = new ScriptingDefinitionStub();
|
||||
const collection = new CategoryCollectionStub()
|
||||
.withAction(new CategoryStub(0).withScriptIds('scriptId'));
|
||||
const selectionStub = new UserSelection(collection, []);
|
||||
const expectedCodeGenerator = new ApplicationCode(selectionStub, collection.scripting);
|
||||
selectionStub.selectAll();
|
||||
const expectedCode = expectedCodeGenerator.current;
|
||||
.withScripting(expectedScripting);
|
||||
let actualScripting: IScriptingDefinition | undefined;
|
||||
const codeFactoryMock: CodeFactory = (_, scripting) => {
|
||||
actualScripting = scripting;
|
||||
return new ApplicationCodeStub();
|
||||
};
|
||||
// act
|
||||
const sut = new CategoryCollectionState(collection);
|
||||
sut.selection.selectAll();
|
||||
const actualCode = sut.code.current;
|
||||
new CategoryCollectionStateBuilder()
|
||||
.withCollection(collection)
|
||||
.withCodeFactory(codeFactoryMock)
|
||||
.build();
|
||||
// assert
|
||||
expect(actualCode).to.equal(expectedCode);
|
||||
expectExists(actualScripting);
|
||||
expect(actualScripting).to.equal(expectedScripting);
|
||||
});
|
||||
it('initializes with the expected script selection', () => {
|
||||
// arrange
|
||||
const expectedScriptSelection = new ScriptSelectionStub();
|
||||
const selectionFactoryMock: SelectionFactory = () => {
|
||||
return new UserSelectionStub().withScripts(expectedScriptSelection);
|
||||
};
|
||||
let actualScriptSelection: ReadonlyScriptSelection | undefined;
|
||||
const codeFactoryMock: CodeFactory = (scriptSelection) => {
|
||||
actualScriptSelection = scriptSelection;
|
||||
return new ApplicationCodeStub();
|
||||
};
|
||||
// act
|
||||
new CategoryCollectionStateBuilder()
|
||||
.withCodeFactory(codeFactoryMock)
|
||||
.withSelectionFactory(selectionFactoryMock)
|
||||
.build();
|
||||
// assert
|
||||
expectExists(actualScriptSelection);
|
||||
expect(actualScriptSelection).to.equal(expectedScriptSelection);
|
||||
});
|
||||
});
|
||||
describe('os', () => {
|
||||
it('same as its collection', () => {
|
||||
it('matches the operating system of the collection', () => {
|
||||
// arrange
|
||||
const expected = OperatingSystem.macOS;
|
||||
const collection = new CategoryCollectionStub()
|
||||
.withOs(expected);
|
||||
// act
|
||||
const sut = new CategoryCollectionState(collection);
|
||||
const sut = new CategoryCollectionStateBuilder()
|
||||
.withCollection(collection)
|
||||
.build();
|
||||
// assert
|
||||
const actual = sut.os;
|
||||
expect(expected).to.equal(actual);
|
||||
});
|
||||
});
|
||||
describe('selection', () => {
|
||||
it('initialized with no selection', () => {
|
||||
it('initializes with empty scripts', () => {
|
||||
// arrange
|
||||
const collection = new CategoryCollectionStub();
|
||||
const sut = new CategoryCollectionState(collection);
|
||||
const expectedScripts = [];
|
||||
let actualScripts: readonly SelectedScript[] | undefined;
|
||||
const selectionFactoryMock: SelectionFactory = (_, scripts) => {
|
||||
actualScripts = scripts;
|
||||
return new UserSelectionStub();
|
||||
};
|
||||
// act
|
||||
const actual = sut.selection.selectedScripts.length;
|
||||
new CategoryCollectionStateBuilder()
|
||||
.withSelectionFactory(selectionFactoryMock)
|
||||
.build();
|
||||
// assert
|
||||
expect(actual).to.equal(0);
|
||||
expectExists(actualScripts);
|
||||
expect(actualScripts).to.deep.equal(expectedScripts);
|
||||
});
|
||||
it('can select a script from current collection', () => {
|
||||
it('initializes with the provided collection', () => {
|
||||
// arrange
|
||||
const expectedScript = new ScriptStub('scriptId');
|
||||
const collection = new CategoryCollectionStub()
|
||||
.withAction(new CategoryStub(0).withScript(expectedScript));
|
||||
const sut = new CategoryCollectionState(collection);
|
||||
const expectedCollection = new CategoryCollectionStub();
|
||||
let actualCollection: ICategoryCollection | undefined;
|
||||
const selectionFactoryMock: SelectionFactory = (collection) => {
|
||||
actualCollection = collection;
|
||||
return new UserSelectionStub();
|
||||
};
|
||||
// act
|
||||
sut.selection.selectAll();
|
||||
new CategoryCollectionStateBuilder()
|
||||
.withCollection(expectedCollection)
|
||||
.withSelectionFactory(selectionFactoryMock)
|
||||
.build();
|
||||
// assert
|
||||
expect(sut.selection.selectedScripts.length).to.equal(1);
|
||||
expect(sut.selection.isSelected(expectedScript.id)).to.equal(true);
|
||||
expectExists(actualCollection);
|
||||
expect(actualCollection).to.equal(expectedCollection);
|
||||
});
|
||||
});
|
||||
describe('filter', () => {
|
||||
it('initialized with an empty filter', () => {
|
||||
it('initializes with the provided collection for filtering', () => {
|
||||
// arrange
|
||||
const collection = new CategoryCollectionStub();
|
||||
const sut = new CategoryCollectionState(collection);
|
||||
const expectedCollection = new CategoryCollectionStub();
|
||||
let actualCollection: ICategoryCollection | undefined;
|
||||
const filterFactoryMock: FilterFactory = (collection) => {
|
||||
actualCollection = collection;
|
||||
return new UserFilterStub();
|
||||
};
|
||||
// act
|
||||
const actual = sut.filter.currentFilter;
|
||||
new CategoryCollectionStateBuilder()
|
||||
.withCollection(expectedCollection)
|
||||
.withFilterFactory(filterFactoryMock)
|
||||
.build();
|
||||
// assert
|
||||
expect(actual).to.equal(undefined);
|
||||
});
|
||||
it('can match a script from current collection', () => {
|
||||
// arrange
|
||||
const scriptNameFilter = 'scriptName';
|
||||
const expectedScript = new ScriptStub('scriptId')
|
||||
.withName(scriptNameFilter);
|
||||
const collection = new CategoryCollectionStub()
|
||||
.withAction(new CategoryStub(0).withScript(expectedScript));
|
||||
const sut = new CategoryCollectionState(collection);
|
||||
// act
|
||||
let actualScript: IScript | undefined;
|
||||
sut.filter.filterChanged.on((result) => {
|
||||
result.visit({
|
||||
onApply: (filter) => {
|
||||
[actualScript] = filter.scriptMatches;
|
||||
},
|
||||
});
|
||||
});
|
||||
sut.filter.applyFilter(scriptNameFilter);
|
||||
// assert
|
||||
expect(expectedScript).to.equal(actualScript);
|
||||
expectExists(expectedCollection);
|
||||
expect(expectedCollection).to.equal(actualCollection);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class CategoryCollectionStateBuilder {
|
||||
private collection: ICategoryCollection = new CategoryCollectionStub();
|
||||
|
||||
private codeFactory: CodeFactory = () => new ApplicationCodeStub();
|
||||
|
||||
private selectionFactory: SelectionFactory = () => new UserSelectionStub();
|
||||
|
||||
private filterFactory: FilterFactory = () => new UserFilterStub();
|
||||
|
||||
public withCollection(collection: ICategoryCollection): this {
|
||||
this.collection = collection;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withCodeFactory(codeFactory: CodeFactory): this {
|
||||
this.codeFactory = codeFactory;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withSelectionFactory(selectionFactory: SelectionFactory): this {
|
||||
this.selectionFactory = selectionFactory;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withFilterFactory(filterFactory: FilterFactory): this {
|
||||
this.filterFactory = filterFactory;
|
||||
return this;
|
||||
}
|
||||
|
||||
public build() {
|
||||
return new CategoryCollectionState(
|
||||
this.collection,
|
||||
this.selectionFactory,
|
||||
this.codeFactory,
|
||||
this.filterFactory,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
|
||||
import { ApplicationCode } from '@/application/Context/State/Code/ApplicationCode';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import { ICodeChangedEvent } from '@/application/Context/State/Code/Event/ICodeChangedEvent';
|
||||
import { IUserScriptGenerator } from '@/application/Context/State/Code/Generation/IUserScriptGenerator';
|
||||
import { CodePosition } from '@/application/Context/State/Code/Position/CodePosition';
|
||||
@@ -9,16 +7,18 @@ import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePo
|
||||
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
|
||||
import { IUserScript } from '@/application/Context/State/Code/Generation/IUserScript';
|
||||
import { ScriptingDefinitionStub } from '@tests/unit/shared/Stubs/ScriptingDefinitionStub';
|
||||
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
|
||||
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
|
||||
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
|
||||
import { ScriptSelectionStub } from '@tests/unit/shared/Stubs/ScriptSelectionStub';
|
||||
|
||||
describe('ApplicationCode', () => {
|
||||
describe('ctor', () => {
|
||||
it('empty when selection is empty', () => {
|
||||
// arrange
|
||||
const selection = new UserSelection(new CategoryCollectionStub(), []);
|
||||
const selectedScripts = [];
|
||||
const selection = new ScriptSelectionStub()
|
||||
.withSelectedScripts(selectedScripts);
|
||||
const definition = new ScriptingDefinitionStub();
|
||||
const sut = new ApplicationCode(selection, definition);
|
||||
// act
|
||||
@@ -29,10 +29,9 @@ describe('ApplicationCode', () => {
|
||||
it('generates code from script generator when selection is not empty', () => {
|
||||
// arrange
|
||||
const scripts = [new ScriptStub('first'), new ScriptStub('second')];
|
||||
const collection = new CategoryCollectionStub()
|
||||
.withAction(new CategoryStub(1).withScripts(...scripts));
|
||||
const selectedScripts = scripts.map((script) => script.toSelectedScript());
|
||||
const selection = new UserSelection(collection, selectedScripts);
|
||||
const selection = new ScriptSelectionStub()
|
||||
.withSelectedScripts(selectedScripts);
|
||||
const definition = new ScriptingDefinitionStub();
|
||||
const expected: IUserScript = {
|
||||
code: 'expected-code',
|
||||
@@ -53,10 +52,9 @@ describe('ApplicationCode', () => {
|
||||
// arrange
|
||||
let signaled: ICodeChangedEvent | undefined;
|
||||
const scripts = [new ScriptStub('first'), new ScriptStub('second')];
|
||||
const collection = new CategoryCollectionStub()
|
||||
.withAction(new CategoryStub(1).withScripts(...scripts));
|
||||
const scriptsToSelect = scripts.map((script) => new SelectedScript(script, false));
|
||||
const selection = new UserSelection(collection, scriptsToSelect);
|
||||
const selectedScripts = scripts.map((script) => script.toSelectedScript());
|
||||
const selection = new ScriptSelectionStub()
|
||||
.withSelectedScripts(selectedScripts);
|
||||
const definition = new ScriptingDefinitionStub();
|
||||
const sut = new ApplicationCode(selection, definition);
|
||||
sut.changed.on((code) => {
|
||||
@@ -73,17 +71,18 @@ describe('ApplicationCode', () => {
|
||||
// arrange
|
||||
let signaled: ICodeChangedEvent | undefined;
|
||||
const scripts = [new ScriptStub('first'), new ScriptStub('second')];
|
||||
const collection = new CategoryCollectionStub()
|
||||
.withAction(new CategoryStub(1).withScripts(...scripts));
|
||||
const scriptsToSelect = scripts.map((script) => new SelectedScript(script, false));
|
||||
const selection = new UserSelection(collection, scriptsToSelect);
|
||||
const selectedScripts = scripts.map(
|
||||
(script) => script.toSelectedScript().withRevert(false),
|
||||
);
|
||||
const selection = new ScriptSelectionStub()
|
||||
.withSelectedScripts(selectedScripts);
|
||||
const definition = new ScriptingDefinitionStub();
|
||||
const sut = new ApplicationCode(selection, definition);
|
||||
sut.changed.on((code) => {
|
||||
signaled = code;
|
||||
});
|
||||
// act
|
||||
selection.changed.notify(scripts.map((s) => new SelectedScript(s, false)));
|
||||
selection.changed.notify(selectedScripts);
|
||||
// assert
|
||||
expectExists(signaled);
|
||||
expect(signaled.code).to.have.length.greaterThan(0);
|
||||
@@ -94,8 +93,8 @@ describe('ApplicationCode', () => {
|
||||
it('sends scripting definition to generator', () => {
|
||||
// arrange
|
||||
const expectedDefinition = new ScriptingDefinitionStub();
|
||||
const collection = new CategoryCollectionStub();
|
||||
const selection = new UserSelection(collection, []);
|
||||
const selection = new ScriptSelectionStub()
|
||||
.withSelectedScripts([]);
|
||||
const generatorMock: IUserScriptGenerator = {
|
||||
buildCode: (_, definition) => {
|
||||
if (definition !== expectedDefinition) {
|
||||
@@ -118,13 +117,12 @@ describe('ApplicationCode', () => {
|
||||
// arrange
|
||||
const expectedDefinition = new ScriptingDefinitionStub();
|
||||
const scripts = [new ScriptStub('first'), new ScriptStub('second')];
|
||||
const collection = new CategoryCollectionStub()
|
||||
.withAction(new CategoryStub(1).withScripts(...scripts));
|
||||
const scriptsToSelect = scripts.map((script) => new SelectedScript(script, false));
|
||||
const selection = new UserSelection(collection, scriptsToSelect);
|
||||
const selectedScripts = scripts.map((script) => script.toSelectedScript());
|
||||
const selection = new ScriptSelectionStub()
|
||||
.withSelectedScripts(selectedScripts);
|
||||
const generatorMock: IUserScriptGenerator = {
|
||||
buildCode: (selectedScripts) => {
|
||||
if (JSON.stringify(selectedScripts) !== JSON.stringify(scriptsToSelect)) {
|
||||
buildCode: (actualScripts) => {
|
||||
if (JSON.stringify(actualScripts) !== JSON.stringify(selectedScripts)) {
|
||||
throw new Error('Unexpected scripts');
|
||||
}
|
||||
return {
|
||||
@@ -136,7 +134,7 @@ describe('ApplicationCode', () => {
|
||||
// eslint-disable-next-line no-new
|
||||
new ApplicationCode(selection, expectedDefinition, generatorMock);
|
||||
// act
|
||||
const act = () => selection.changed.notify(scriptsToSelect);
|
||||
const act = () => selection.changed.notify(selectedScripts);
|
||||
// assert
|
||||
expect(act).to.not.throw();
|
||||
});
|
||||
@@ -144,16 +142,17 @@ describe('ApplicationCode', () => {
|
||||
// arrange
|
||||
let signaled: ICodeChangedEvent | undefined;
|
||||
const scripts = [new ScriptStub('first'), new ScriptStub('second')];
|
||||
const collection = new CategoryCollectionStub()
|
||||
.withAction(new CategoryStub(1).withScripts(...scripts));
|
||||
const scriptsToSelect = scripts.map((script) => new SelectedScript(script, false));
|
||||
const selection = new UserSelection(collection, scriptsToSelect);
|
||||
const selectedScripts = scripts.map(
|
||||
(script) => script.toSelectedScript().withRevert(false),
|
||||
);
|
||||
const selection = new ScriptSelectionStub()
|
||||
.withSelectedScripts(selectedScripts);
|
||||
const scriptingDefinition = new ScriptingDefinitionStub();
|
||||
const totalLines = 20;
|
||||
const expected = new Map<SelectedScript, ICodePosition>(
|
||||
[
|
||||
[scriptsToSelect[0], new CodePosition(0, totalLines / 2)],
|
||||
[scriptsToSelect[1], new CodePosition(totalLines / 2, totalLines)],
|
||||
[selectedScripts[0], new CodePosition(0, totalLines / 2)],
|
||||
[selectedScripts[1], new CodePosition(totalLines / 2, totalLines)],
|
||||
],
|
||||
);
|
||||
const generatorMock: IUserScriptGenerator = {
|
||||
@@ -169,27 +168,27 @@ describe('ApplicationCode', () => {
|
||||
signaled = code;
|
||||
});
|
||||
// act
|
||||
selection.changed.notify(scriptsToSelect);
|
||||
selection.changed.notify(selectedScripts);
|
||||
// assert
|
||||
expectExists(signaled);
|
||||
expect(signaled.getScriptPositionInCode(scripts[0]))
|
||||
.to.deep.equal(expected.get(scriptsToSelect[0]));
|
||||
.to.deep.equal(expected.get(selectedScripts[0]));
|
||||
expect(signaled.getScriptPositionInCode(scripts[1]))
|
||||
.to.deep.equal(expected.get(scriptsToSelect[1]));
|
||||
.to.deep.equal(expected.get(selectedScripts[1]));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
interface IScriptGenerationParameters {
|
||||
scripts: readonly SelectedScript[];
|
||||
definition: IScriptingDefinition;
|
||||
interface ScriptGenerationParameters {
|
||||
readonly scripts: readonly SelectedScript[];
|
||||
readonly definition: IScriptingDefinition;
|
||||
}
|
||||
class UserScriptGeneratorMock implements IUserScriptGenerator {
|
||||
private prePlanned = new Map<IScriptGenerationParameters, IUserScript>();
|
||||
private prePlanned = new Map<ScriptGenerationParameters, IUserScript>();
|
||||
|
||||
public plan(
|
||||
parameters: IScriptGenerationParameters,
|
||||
parameters: ScriptGenerationParameters,
|
||||
result: IUserScript,
|
||||
): UserScriptGeneratorMock {
|
||||
this.prePlanned.set(parameters, result);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CodeChangedEvent } from '@/application/Context/State/Code/Event/CodeChangedEvent';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
|
||||
import { CodePosition } from '@/application/Context/State/Code/Position/CodePosition';
|
||||
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
|
||||
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
|
||||
|
||||
describe('CodeChangedEvent', () => {
|
||||
describe('ctor', () => {
|
||||
@@ -15,8 +15,8 @@ describe('CodeChangedEvent', () => {
|
||||
const nonExistingLine1 = 2;
|
||||
const nonExistingLine2 = 31;
|
||||
const newScripts = new Map<SelectedScript, ICodePosition>([
|
||||
[new SelectedScriptStub('1'), new CodePosition(0, nonExistingLine1)],
|
||||
[new SelectedScriptStub('2'), new CodePosition(0, nonExistingLine2)],
|
||||
[new SelectedScriptStub(new ScriptStub('1')), new CodePosition(0, nonExistingLine1)],
|
||||
[new SelectedScriptStub(new ScriptStub('2')), new CodePosition(0, nonExistingLine2)],
|
||||
]);
|
||||
// act
|
||||
let errorText = '';
|
||||
@@ -47,7 +47,7 @@ describe('CodeChangedEvent', () => {
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
const newScripts = new Map<SelectedScript, ICodePosition>([
|
||||
[new SelectedScriptStub('1'), testCase.position],
|
||||
[new SelectedScriptStub(new ScriptStub('1')), testCase.position],
|
||||
]);
|
||||
// act
|
||||
const act = () => new CodeChangedEventBuilder()
|
||||
@@ -76,12 +76,15 @@ describe('CodeChangedEvent', () => {
|
||||
it('returns new scripts when scripts are added', () => {
|
||||
// arrange
|
||||
const expected = [new ScriptStub('3'), new ScriptStub('4')];
|
||||
const initialScripts = [new SelectedScriptStub('1'), new SelectedScriptStub('2')];
|
||||
const initialScripts = [
|
||||
new SelectedScriptStub(new ScriptStub('1')),
|
||||
new SelectedScriptStub(new ScriptStub('2')),
|
||||
];
|
||||
const newScripts = new Map<SelectedScript, ICodePosition>([
|
||||
[initialScripts[0], new CodePosition(0, 1)],
|
||||
[initialScripts[1], new CodePosition(0, 1)],
|
||||
[new SelectedScript(expected[0], false), new CodePosition(0, 1)],
|
||||
[new SelectedScript(expected[1], false), new CodePosition(0, 1)],
|
||||
[new SelectedScriptStub(expected[0]).withRevert(false), new CodePosition(0, 1)],
|
||||
[new SelectedScriptStub(expected[1]).withRevert(false), new CodePosition(0, 1)],
|
||||
]);
|
||||
const sut = new CodeChangedEventBuilder()
|
||||
.withOldScripts(initialScripts)
|
||||
@@ -98,8 +101,13 @@ describe('CodeChangedEvent', () => {
|
||||
describe('removedScripts', () => {
|
||||
it('returns removed scripts when script are removed', () => {
|
||||
// arrange
|
||||
const existingScripts = [new SelectedScriptStub('0'), new SelectedScriptStub('1')];
|
||||
const removedScripts = [new SelectedScriptStub('2')];
|
||||
const existingScripts = [
|
||||
new SelectedScriptStub(new ScriptStub('0')),
|
||||
new SelectedScriptStub(new ScriptStub('1')),
|
||||
];
|
||||
const removedScripts = [
|
||||
new SelectedScriptStub(new ScriptStub('2')),
|
||||
];
|
||||
const initialScripts = [...existingScripts, ...removedScripts];
|
||||
const newScripts = new Map<SelectedScript, ICodePosition>([
|
||||
[initialScripts[0], new CodePosition(0, 1)],
|
||||
@@ -119,10 +127,17 @@ describe('CodeChangedEvent', () => {
|
||||
describe('changedScripts', () => {
|
||||
it('returns changed scripts when scripts are changed', () => {
|
||||
// arrange
|
||||
const initialScripts = [new SelectedScriptStub('1', false), new SelectedScriptStub('2', false)];
|
||||
const changedScripts = [
|
||||
new ScriptStub('scripts-with-changed-selection-1'),
|
||||
new ScriptStub('scripts-with-changed-selection-2'),
|
||||
];
|
||||
const initialScripts = [
|
||||
new SelectedScriptStub(changedScripts[0]).withRevert(false),
|
||||
new SelectedScriptStub(changedScripts[1]).withRevert(false),
|
||||
];
|
||||
const newScripts = new Map<SelectedScript, ICodePosition>([
|
||||
[new SelectedScriptStub('1', true), new CodePosition(0, 1)],
|
||||
[new SelectedScriptStub('2', false), new CodePosition(0, 1)],
|
||||
[new SelectedScriptStub(changedScripts[0]).withRevert(true), new CodePosition(0, 1)],
|
||||
[new SelectedScriptStub(changedScripts[1]).withRevert(false), new CodePosition(0, 1)],
|
||||
]);
|
||||
const sut = new CodeChangedEventBuilder()
|
||||
.withOldScripts(initialScripts)
|
||||
@@ -139,7 +154,7 @@ describe('CodeChangedEvent', () => {
|
||||
it('returns true when empty', () => {
|
||||
// arrange
|
||||
const newScripts = new Map<SelectedScript, ICodePosition>();
|
||||
const oldScripts = [new SelectedScriptStub('1', false)];
|
||||
const oldScripts = [new SelectedScriptStub(new ScriptStub('1')).withRevert(false)];
|
||||
const sut = new CodeChangedEventBuilder()
|
||||
.withOldScripts(oldScripts)
|
||||
.withNewScripts(newScripts)
|
||||
@@ -151,7 +166,7 @@ describe('CodeChangedEvent', () => {
|
||||
});
|
||||
it('returns false when not empty', () => {
|
||||
// arrange
|
||||
const oldScripts = [new SelectedScriptStub('1')];
|
||||
const oldScripts = [new SelectedScriptStub(new ScriptStub('1'))];
|
||||
const newScripts = new Map<SelectedScript, ICodePosition>([
|
||||
[oldScripts[0], new CodePosition(0, 1)],
|
||||
]);
|
||||
@@ -182,7 +197,7 @@ describe('CodeChangedEvent', () => {
|
||||
const script = new ScriptStub('1');
|
||||
const expected = new CodePosition(0, 1);
|
||||
const newScripts = new Map<SelectedScript, ICodePosition>([
|
||||
[new SelectedScript(script, false), expected],
|
||||
[new SelectedScriptStub(script).withRevert(false), expected],
|
||||
]);
|
||||
const sut = new CodeChangedEventBuilder()
|
||||
.withNewScripts(newScripts)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { UserScriptGenerator } from '@/application/Context/State/Code/Generation/UserScriptGenerator';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import { ICodeBuilderFactory } from '@/application/Context/State/Code/Generation/ICodeBuilderFactory';
|
||||
import { ICodeBuilder } from '@/application/Context/State/Code/Generation/ICodeBuilder';
|
||||
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
|
||||
import { ScriptingDefinitionStub } from '@tests/unit/shared/Stubs/ScriptingDefinitionStub';
|
||||
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
|
||||
|
||||
describe('UserScriptGenerator', () => {
|
||||
describe('scriptingDefinition', () => {
|
||||
@@ -94,7 +94,7 @@ describe('UserScriptGenerator', () => {
|
||||
const scriptName = 'test non-revert script';
|
||||
const scriptCode = 'REM nop';
|
||||
const script = new ScriptStub('id').withName(scriptName).withCode(scriptCode);
|
||||
const selectedScripts = [new SelectedScript(script, false)];
|
||||
const selectedScripts = [new SelectedScriptStub(script).withRevert(false)];
|
||||
const definition = new ScriptingDefinitionStub();
|
||||
// act
|
||||
const actual = sut.buildCode(selectedScripts, definition);
|
||||
@@ -113,7 +113,8 @@ describe('UserScriptGenerator', () => {
|
||||
const script = new ScriptStub('id')
|
||||
.withName(scriptName)
|
||||
.withRevertCode(scriptCode)
|
||||
.toSelectedScript(true);
|
||||
.toSelectedScript()
|
||||
.withRevert(true);
|
||||
const definition = new ScriptingDefinitionStub();
|
||||
// act
|
||||
const actual = sut.buildCode([script], definition);
|
||||
@@ -127,10 +128,9 @@ describe('UserScriptGenerator', () => {
|
||||
const expectedError = 'Reverted script lacks revert code.';
|
||||
const sut = new UserScriptGenerator();
|
||||
const script = new ScriptStub('id')
|
||||
.toSelectedScript(true);
|
||||
// Hack until SelectedScript is interface:
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(script.script.code as any).revert = emptyRevertCode;
|
||||
.withRevertCode(emptyRevertCode)
|
||||
.toSelectedScript()
|
||||
.withRevert(true);
|
||||
const definition = new ScriptingDefinitionStub();
|
||||
// act
|
||||
const act = () => sut.buildCode([script], definition);
|
||||
@@ -181,7 +181,8 @@ describe('UserScriptGenerator', () => {
|
||||
const selectedScript = new ScriptStub('script-id')
|
||||
.withName('script')
|
||||
.withCode(testCase.scriptCode)
|
||||
.toSelectedScript(false);
|
||||
.toSelectedScript()
|
||||
.withRevert(false);
|
||||
// act
|
||||
const actual = sut.buildCode([selectedScript], definition);
|
||||
// expect
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import { UserSelectedScript } from '@/application/Context/State/Selection/Script/UserSelectedScript';
|
||||
|
||||
describe('SelectedScript', () => {
|
||||
describe('UserSelectedScript', () => {
|
||||
it('id is same as script id', () => {
|
||||
// arrange
|
||||
const expectedId = 'scriptId';
|
||||
const script = new ScriptStub(expectedId);
|
||||
const sut = new SelectedScript(script, false);
|
||||
const sut = new UserSelectedScript(script, false);
|
||||
// act
|
||||
const actualId = sut.id;
|
||||
// assert
|
||||
@@ -15,13 +15,13 @@ describe('SelectedScript', () => {
|
||||
});
|
||||
it('throws when revert is true for irreversible script', () => {
|
||||
// arrange
|
||||
const expectedId = 'scriptId';
|
||||
const script = new ScriptStub(expectedId)
|
||||
const scriptId = 'irreversibleScriptId';
|
||||
const expectedError = `The script with ID '${scriptId}' is not reversible and cannot be reverted.`;
|
||||
const script = new ScriptStub(scriptId)
|
||||
.withRevertCode(undefined);
|
||||
// act
|
||||
// eslint-disable-next-line no-new
|
||||
function construct() { new SelectedScript(script, true); }
|
||||
const act = () => new UserSelectedScript(script, true);
|
||||
// assert
|
||||
expect(construct).to.throw('cannot revert an irreversible script');
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(' , ')
|
||||
}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
|
||||
import { RecommendationLevel } from '@/domain/RecommendationLevel';
|
||||
import { CategoryCollectionStateStub } from '@tests/unit/shared/Stubs/CategoryCollectionStateStub';
|
||||
import { ScriptSelectionStub } from '@tests/unit/shared/Stubs/ScriptSelectionStub';
|
||||
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
|
||||
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
|
||||
import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub';
|
||||
|
||||
export class SelectionStateTestScenario {
|
||||
public readonly all: readonly SelectedScript[];
|
||||
@@ -28,13 +31,21 @@ export class SelectionStateTestScenario {
|
||||
this.all = [...this.allStandard, ...this.allStrict, ...this.allUnrecommended];
|
||||
}
|
||||
|
||||
public generateState(selectedScripts: readonly SelectedScript[]) {
|
||||
public generateState(selectedScripts: readonly SelectedScript[] = []) {
|
||||
const allScripts = this.all.map((s) => s.script);
|
||||
return new CategoryCollectionStateStub(allScripts)
|
||||
const scriptSelection = new ScriptSelectionStub()
|
||||
.withSelectedScripts(selectedScripts);
|
||||
const categoryCollectionState = new CategoryCollectionStateStub(allScripts)
|
||||
.withSelection(new UserSelectionStub().withScripts(scriptSelection));
|
||||
return {
|
||||
scriptsStub: scriptSelection,
|
||||
stateStub: categoryCollectionState,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function createSelectedScripts(level?: RecommendationLevel, ...ids: string[]) {
|
||||
return ids.map((id) => new SelectedScript(new ScriptStub(id).withLevel(level), false));
|
||||
function createSelectedScripts(level?: RecommendationLevel, ...ids: string[]): SelectedScript[] {
|
||||
return ids.map((id) => new SelectedScriptStub(
|
||||
new ScriptStub(id).withLevel(level),
|
||||
).withRevert(false));
|
||||
}
|
||||
|
||||
@@ -3,10 +3,14 @@ import {
|
||||
SelectionCheckContext, SelectionMutationContext, SelectionType,
|
||||
getCurrentSelectionType, setCurrentSelectionType,
|
||||
} from '@/presentation/components/Scripts/Menu/Selector/SelectionTypeHandler';
|
||||
import { scrambledEqual } from '@/application/Common/Array';
|
||||
import { RecommendationLevel } from '@/domain/RecommendationLevel';
|
||||
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import { EnumRangeTestRunner } from '@tests/unit/application/Common/EnumRangeTestRunner';
|
||||
import { ScriptSelection } from '@/application/Context/State/Selection/Script/ScriptSelection';
|
||||
import { MethodCall } from '@tests/unit/shared/Stubs/StubWithObservableMethodCalls';
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
import { scrambledEqual } from '@/application/Common/Array';
|
||||
import { IScript } from '@/domain/IScript';
|
||||
import { SelectionStateTestScenario } from './SelectionStateTestScenario';
|
||||
|
||||
describe('SelectionTypeHandler', () => {
|
||||
@@ -14,11 +18,11 @@ describe('SelectionTypeHandler', () => {
|
||||
describe('throws with invalid type', () => {
|
||||
// arrange
|
||||
const scenario = new SelectionStateTestScenario();
|
||||
const state = scenario.generateState([]);
|
||||
const { stateStub } = scenario.generateState([]);
|
||||
// act
|
||||
const act = (type: SelectionType) => setCurrentSelectionType(
|
||||
type,
|
||||
createMutationContext(state),
|
||||
createMutationContext(stateStub),
|
||||
);
|
||||
// assert
|
||||
new EnumRangeTestRunner(act)
|
||||
@@ -28,44 +32,64 @@ describe('SelectionTypeHandler', () => {
|
||||
describe('select types as expected', () => {
|
||||
// arrange
|
||||
const scenario = new SelectionStateTestScenario();
|
||||
const initialScriptsCases = [{
|
||||
name: 'when nothing is selected',
|
||||
initialScripts: [],
|
||||
}, {
|
||||
name: 'when some scripts are selected',
|
||||
initialScripts: [...scenario.allStandard, ...scenario.someStrict],
|
||||
}, {
|
||||
name: 'when all scripts are selected',
|
||||
initialScripts: scenario.all,
|
||||
}];
|
||||
for (const initialScriptsCase of initialScriptsCases) {
|
||||
describe(initialScriptsCase.name, () => {
|
||||
const state = scenario.generateState(initialScriptsCase.initialScripts);
|
||||
const typeExpectations = [{
|
||||
input: SelectionType.None,
|
||||
output: [],
|
||||
}, {
|
||||
input: SelectionType.Standard,
|
||||
output: scenario.allStandard,
|
||||
}, {
|
||||
input: SelectionType.Strict,
|
||||
output: [...scenario.allStandard, ...scenario.allStrict],
|
||||
}, {
|
||||
input: SelectionType.All,
|
||||
output: scenario.all,
|
||||
}];
|
||||
for (const expectation of typeExpectations) {
|
||||
// act
|
||||
it(`${SelectionType[expectation.input]} returns as expected`, () => {
|
||||
setCurrentSelectionType(expectation.input, createMutationContext(state));
|
||||
// assert
|
||||
const actual = state.selection.selectedScripts;
|
||||
const expected = expectation.output;
|
||||
expect(scrambledEqual(actual, expected));
|
||||
});
|
||||
const testScenarios: ReadonlyArray<{
|
||||
readonly givenType: SelectionType;
|
||||
readonly expectedCall: MethodCall<ScriptSelection>;
|
||||
}> = [
|
||||
{
|
||||
givenType: SelectionType.None,
|
||||
expectedCall: {
|
||||
methodName: 'deselectAll',
|
||||
args: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
givenType: SelectionType.Standard,
|
||||
expectedCall: {
|
||||
methodName: 'selectOnly',
|
||||
args: [
|
||||
scenario.allStandard.map((s) => s.script),
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
givenType: SelectionType.Strict,
|
||||
expectedCall: {
|
||||
methodName: 'selectOnly',
|
||||
args: [[
|
||||
...scenario.allStandard.map((s) => s.script),
|
||||
...scenario.allStrict.map((s) => s.script),
|
||||
]],
|
||||
},
|
||||
},
|
||||
{
|
||||
givenType: SelectionType.All,
|
||||
expectedCall: {
|
||||
methodName: 'selectAll',
|
||||
args: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
testScenarios.forEach(({
|
||||
givenType, expectedCall,
|
||||
}) => {
|
||||
it(`${SelectionType[givenType]} modifies as expected`, () => {
|
||||
const { stateStub, scriptsStub } = scenario.generateState();
|
||||
// act
|
||||
setCurrentSelectionType(givenType, createMutationContext(stateStub));
|
||||
// assert
|
||||
const call = scriptsStub.callHistory.find(
|
||||
(c) => c.methodName === expectedCall.methodName,
|
||||
);
|
||||
expectExists(call);
|
||||
if (expectedCall.args.length > 0) { /** {@link ScriptSelection.selectOnly}. */
|
||||
expect(scrambledEqual(
|
||||
call.args[0] as IScript[],
|
||||
expectedCall.args[0] as IScript[],
|
||||
)).to.equal(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('getCurrentSelectionType', () => {
|
||||
@@ -106,9 +130,9 @@ describe('SelectionTypeHandler', () => {
|
||||
}];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
const state = scenario.generateState(testCase.selection);
|
||||
const { stateStub } = scenario.generateState(testCase.selection);
|
||||
// act
|
||||
const actual = getCurrentSelectionType(createCheckContext(state));
|
||||
const actual = getCurrentSelectionType(createCheckContext(stateStub));
|
||||
// assert
|
||||
expect(actual).to.deep.equal(
|
||||
testCase.expected,
|
||||
@@ -130,14 +154,14 @@ describe('SelectionTypeHandler', () => {
|
||||
|
||||
function createMutationContext(state: ICategoryCollectionState): SelectionMutationContext {
|
||||
return {
|
||||
selection: state.selection,
|
||||
selection: state.selection.scripts,
|
||||
collection: state.collection,
|
||||
};
|
||||
}
|
||||
|
||||
function createCheckContext(state: ICategoryCollectionState): SelectionCheckContext {
|
||||
return {
|
||||
selection: state.selection,
|
||||
selection: state.selection.scripts,
|
||||
collection: state.collection,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,106 +1,101 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CategoryReverter } from '@/presentation/components/Scripts/View/Tree/NodeContent/Reverter/CategoryReverter';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
|
||||
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
|
||||
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
|
||||
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
|
||||
import { getCategoryNodeId } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter';
|
||||
import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub';
|
||||
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
|
||||
import { CategorySelectionStub } from '@tests/unit/shared/Stubs/CategorySelectionStub';
|
||||
|
||||
describe('CategoryReverter', () => {
|
||||
describe('getState', () => {
|
||||
// arrange
|
||||
const scripts = [
|
||||
new ScriptStub('revertable').withRevertCode('REM revert me'),
|
||||
new ScriptStub('revertable2').withRevertCode('REM revert me 2'),
|
||||
new ScriptStub('reversible').withRevertCode('REM revert me'),
|
||||
new ScriptStub('reversible2').withRevertCode('REM revert me 2'),
|
||||
];
|
||||
const category = new CategoryStub(1).withScripts(...scripts);
|
||||
const nodeId = getCategoryNodeId(category);
|
||||
const collection = new CategoryCollectionStub().withAction(category);
|
||||
const sut = new CategoryReverter(nodeId, collection);
|
||||
const testCases = [
|
||||
const testScenarios: ReadonlyArray<{
|
||||
readonly description: string;
|
||||
readonly selectedScripts: readonly SelectedScript[];
|
||||
readonly expectedState: boolean;
|
||||
}> = [
|
||||
{
|
||||
name: 'false when subscripts are not reverted',
|
||||
state: scripts.map((script) => new SelectedScript(script, false)),
|
||||
expected: false,
|
||||
description: 'returns `false` for non-reverted subscripts',
|
||||
selectedScripts: scripts.map(
|
||||
(script) => new SelectedScriptStub(script).withRevert(false),
|
||||
),
|
||||
expectedState: false,
|
||||
},
|
||||
{
|
||||
name: 'false when some subscripts are reverted',
|
||||
state: [new SelectedScript(scripts[0], false), new SelectedScript(scripts[0], true)],
|
||||
expected: false,
|
||||
description: 'returns `false` when only some subscripts are reverted',
|
||||
selectedScripts: [
|
||||
new SelectedScriptStub(scripts[0]).withRevert(false),
|
||||
new SelectedScriptStub(scripts[0]).withRevert(true),
|
||||
],
|
||||
expectedState: false,
|
||||
},
|
||||
{
|
||||
name: 'false when subscripts are not reverted',
|
||||
state: scripts.map((script) => new SelectedScript(script, true)),
|
||||
expected: true,
|
||||
description: 'returns `true` when all subscripts are reverted',
|
||||
selectedScripts: scripts.map(
|
||||
(script) => new SelectedScriptStub(script).withRevert(true),
|
||||
),
|
||||
expectedState: true,
|
||||
},
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
testScenarios.forEach((
|
||||
{ description, selectedScripts, expectedState },
|
||||
) => {
|
||||
it(description, () => {
|
||||
// act
|
||||
const actual = sut.getState(testCase.state);
|
||||
const actual = sut.getState(selectedScripts);
|
||||
// assert
|
||||
expect(actual).to.equal(testCase.expected);
|
||||
expect(actual).to.equal(expectedState);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
describe('selectWithRevertState', () => {
|
||||
// arrange
|
||||
const scripts = [
|
||||
new ScriptStub('revertable').withRevertCode('REM revert me'),
|
||||
new ScriptStub('revertable2').withRevertCode('REM revert me 2'),
|
||||
const allScripts = [
|
||||
new ScriptStub('reversible').withRevertCode('REM revert me'),
|
||||
new ScriptStub('reversible2').withRevertCode('REM revert me 2'),
|
||||
];
|
||||
const category = new CategoryStub(1).withScripts(...scripts);
|
||||
const category = new CategoryStub(1).withScripts(...allScripts);
|
||||
const collection = new CategoryCollectionStub().withAction(category);
|
||||
/* eslint-disable object-property-newline */
|
||||
const testCases = [
|
||||
const testScenarios: ReadonlyArray<{
|
||||
readonly description: string;
|
||||
readonly expectedRevert: boolean;
|
||||
}> = [
|
||||
{
|
||||
name: 'selects with revert state when not selected',
|
||||
selection: [],
|
||||
revert: true, expectRevert: true,
|
||||
description: 'selects with revert',
|
||||
expectedRevert: true,
|
||||
},
|
||||
{
|
||||
name: 'selects with non-revert state when not selected',
|
||||
selection: [],
|
||||
revert: false, expectRevert: false,
|
||||
},
|
||||
{
|
||||
name: 'switches when already selected with revert state',
|
||||
selection: scripts.map((script) => new SelectedScript(script, true)),
|
||||
revert: false, expectRevert: false,
|
||||
},
|
||||
{
|
||||
name: 'switches when already selected with not revert state',
|
||||
selection: scripts.map((script) => new SelectedScript(script, false)),
|
||||
revert: true, expectRevert: true,
|
||||
},
|
||||
{
|
||||
name: 'keeps revert state when already selected with revert state',
|
||||
selection: scripts.map((script) => new SelectedScript(script, true)),
|
||||
revert: true, expectRevert: true,
|
||||
},
|
||||
{
|
||||
name: 'keeps revert state deselected when already selected wtih non revert state',
|
||||
selection: scripts.map((script) => new SelectedScript(script, false)),
|
||||
revert: false, expectRevert: false,
|
||||
description: 'selects without revert',
|
||||
expectedRevert: false,
|
||||
},
|
||||
];
|
||||
/* eslint-enable object-property-newline */
|
||||
const nodeId = getCategoryNodeId(category);
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
const selection = new UserSelection(collection, testCase.selection);
|
||||
testScenarios.forEach((
|
||||
{ description, expectedRevert },
|
||||
) => {
|
||||
it(description, () => {
|
||||
const categorySelection = new CategorySelectionStub();
|
||||
const sut = new CategoryReverter(nodeId, collection);
|
||||
const revertState = expectedRevert;
|
||||
// act
|
||||
sut.selectWithRevertState(testCase.revert, selection);
|
||||
sut.selectWithRevertState(
|
||||
revertState,
|
||||
new UserSelectionStub().withCategories(categorySelection),
|
||||
);
|
||||
// assert
|
||||
expect(sut.getState(selection.selectedScripts)).to.equal(testCase.expectRevert);
|
||||
expect(selection.selectedScripts).has.lengthOf(2);
|
||||
expect(selection.selectedScripts[0].id).equal(scripts[0].id);
|
||||
expect(selection.selectedScripts[1].id).equal(scripts[1].id);
|
||||
expect(selection.selectedScripts[0].revert).equal(testCase.expectRevert);
|
||||
expect(selection.selectedScripts[1].revert).equal(testCase.expectRevert);
|
||||
expect(categorySelection.isCategorySelected(category.id, expectedRevert)).to.equal(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,90 +1,118 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ScriptReverter } from '@/presentation/components/Scripts/View/Tree/NodeContent/Reverter/ScriptReverter';
|
||||
import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
|
||||
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
|
||||
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
|
||||
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
|
||||
import { getScriptNodeId } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter';
|
||||
import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
|
||||
import { ScriptSelectionStub } from '@tests/unit/shared/Stubs/ScriptSelectionStub';
|
||||
|
||||
describe('ScriptReverter', () => {
|
||||
describe('getState', () => {
|
||||
it('false when script is not selected', () => {
|
||||
// arrange
|
||||
const script = new ScriptStub('id');
|
||||
const nodeId = getScriptNodeId(script);
|
||||
const sut = new ScriptReverter(nodeId);
|
||||
// act
|
||||
const actual = sut.getState([]);
|
||||
// assert
|
||||
expect(actual).to.equal(false);
|
||||
});
|
||||
it('false when script is selected but not reverted', () => {
|
||||
// arrange
|
||||
const scripts = [new SelectedScriptStub('id'), new SelectedScriptStub('dummy')];
|
||||
const nodeId = getScriptNodeId(scripts[0].script);
|
||||
const sut = new ScriptReverter(nodeId);
|
||||
// act
|
||||
const actual = sut.getState(scripts);
|
||||
// assert
|
||||
expect(actual).to.equal(false);
|
||||
});
|
||||
it('true when script is selected and reverted', () => {
|
||||
// arrange
|
||||
const scripts = [new SelectedScriptStub('id', true), new SelectedScriptStub('dummy')];
|
||||
const nodeId = getScriptNodeId(scripts[0].script);
|
||||
const sut = new ScriptReverter(nodeId);
|
||||
// act
|
||||
const actual = sut.getState(scripts);
|
||||
// assert
|
||||
expect(actual).to.equal(true);
|
||||
// arrange
|
||||
const script = new ScriptStub('id');
|
||||
const nodeId = getScriptNodeId(script);
|
||||
const testScenarios: ReadonlyArray<{
|
||||
readonly description: string;
|
||||
readonly selectedScripts: readonly SelectedScript[];
|
||||
readonly expectedState: boolean;
|
||||
}> = [
|
||||
{
|
||||
description: 'returns `false` when script is not selected',
|
||||
selectedScripts: [],
|
||||
expectedState: false,
|
||||
},
|
||||
{
|
||||
description: 'returns `false` when script is selected but not reverted',
|
||||
selectedScripts: [
|
||||
new SelectedScriptStub(script).withRevert(false),
|
||||
new SelectedScriptStub(new ScriptStub('dummy')),
|
||||
],
|
||||
expectedState: false,
|
||||
},
|
||||
{
|
||||
description: 'returns `true` when script is selected and reverted',
|
||||
selectedScripts: [
|
||||
new SelectedScriptStub(script).withRevert(true),
|
||||
new SelectedScriptStub(new ScriptStub('dummy')),
|
||||
],
|
||||
expectedState: true,
|
||||
},
|
||||
];
|
||||
testScenarios.forEach((
|
||||
{ description, selectedScripts, expectedState },
|
||||
) => {
|
||||
it(description, () => {
|
||||
const sut = new ScriptReverter(nodeId);
|
||||
// act
|
||||
const actual = sut.getState(selectedScripts);
|
||||
// assert
|
||||
expect(actual).to.equal(expectedState);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('selectWithRevertState', () => {
|
||||
// arrange
|
||||
const script = new ScriptStub('id');
|
||||
const collection = new CategoryCollectionStub()
|
||||
.withAction(new CategoryStub(5).withScript(script));
|
||||
/* eslint-disable object-property-newline */
|
||||
const testCases = [
|
||||
const testScenarios: ReadonlyArray<{
|
||||
readonly description: string;
|
||||
readonly selection: readonly SelectedScript[];
|
||||
readonly expectedRevert: boolean;
|
||||
}> = [
|
||||
{
|
||||
name: 'selects with revert state when not selected',
|
||||
selection: [], revert: true, expectRevert: true,
|
||||
description: 'selects as reverted when initially unselected',
|
||||
selection: [],
|
||||
expectedRevert: true,
|
||||
},
|
||||
{
|
||||
name: 'selects with non-revert state when not selected',
|
||||
selection: [], revert: false, expectRevert: false,
|
||||
description: 'selects as non-reverted when initially unselected',
|
||||
selection: [],
|
||||
expectedRevert: false,
|
||||
},
|
||||
{
|
||||
name: 'switches when already selected with revert state',
|
||||
selection: [new SelectedScript(script, true)], revert: false, expectRevert: false,
|
||||
description: 'toggles to non-reverted for previously reverted scripts',
|
||||
selection: [
|
||||
new SelectedScriptStub(script).withRevert(true),
|
||||
],
|
||||
expectedRevert: false,
|
||||
},
|
||||
{
|
||||
name: 'switches when already selected with not revert state',
|
||||
selection: [new SelectedScript(script, false)], revert: true, expectRevert: true,
|
||||
description: 'toggles to reverted for previously non-reverted scripts',
|
||||
selection: [
|
||||
new SelectedScriptStub(script).withRevert(false),
|
||||
],
|
||||
expectedRevert: true,
|
||||
},
|
||||
{
|
||||
name: 'keeps revert state when already selected with revert state',
|
||||
selection: [new SelectedScript(script, true)], revert: true, expectRevert: true,
|
||||
description: 'maintains reverted state for already reverted scripts',
|
||||
selection: [
|
||||
new SelectedScriptStub(script).withRevert(true),
|
||||
],
|
||||
expectedRevert: true,
|
||||
},
|
||||
{
|
||||
name: 'keeps revert state deselected when already selected with non revert state',
|
||||
selection: [new SelectedScript(script, false)], revert: false, expectRevert: false,
|
||||
description: 'maintains non-reverted state for already non-reverted scripts',
|
||||
selection: [
|
||||
new SelectedScriptStub(script).withRevert(false),
|
||||
],
|
||||
expectedRevert: false,
|
||||
},
|
||||
];
|
||||
/* eslint-enable object-property-newline */
|
||||
const nodeId = getScriptNodeId(script);
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
const selection = new UserSelection(collection, testCase.selection);
|
||||
testScenarios.forEach((
|
||||
{ description, selection, expectedRevert },
|
||||
) => {
|
||||
it(description, () => {
|
||||
const scriptSelection = new ScriptSelectionStub()
|
||||
.withSelectedScripts(selection);
|
||||
const userSelection = new UserSelectionStub().withScripts(scriptSelection);
|
||||
const sut = new ScriptReverter(nodeId);
|
||||
const revertState = expectedRevert;
|
||||
// act
|
||||
sut.selectWithRevertState(testCase.revert, selection);
|
||||
sut.selectWithRevertState(revertState, userSelection);
|
||||
// assert
|
||||
expect(selection.isSelected(script.id)).to.equal(true);
|
||||
expect(selection.selectedScripts[0].revert).equal(testCase.expectRevert);
|
||||
expect(scriptSelection.isScriptSelected(script.id, expectedRevert)).to.equal(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TreeNodeCheckState } from '@/presentation/components/Scripts/View/Tree/
|
||||
import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub';
|
||||
import { TreeNodeStateChangedEmittedEventStub } from '@tests/unit/shared/Stubs/TreeNodeStateChangedEmittedEventStub';
|
||||
import { UseUserSelectionStateStub } from '@tests/unit/shared/Stubs/UseUserSelectionStateStub';
|
||||
import { ScriptSelectionStub } from '@tests/unit/shared/Stubs/ScriptSelectionStub';
|
||||
|
||||
describe('useCollectionSelectionStateUpdater', () => {
|
||||
describe('updateNodeSelection', () => {
|
||||
@@ -56,9 +57,12 @@ describe('useCollectionSelectionStateUpdater', () => {
|
||||
it('adds to selection if not already selected', () => {
|
||||
// arrange
|
||||
const { returnObject, useSelectionStateStub } = runHook();
|
||||
const selectionStub = new UserSelectionStub([]);
|
||||
selectionStub.isSelected = () => false;
|
||||
useSelectionStateStub.withUserSelection(selectionStub);
|
||||
const isScriptInitiallySelected = false;
|
||||
const scriptSelectionStub = new ScriptSelectionStub()
|
||||
.withIsSelectedResult(isScriptInitiallySelected);
|
||||
useSelectionStateStub.withUserSelection(
|
||||
new UserSelectionStub().withScripts(scriptSelectionStub),
|
||||
);
|
||||
const node = createTreeNodeStub({
|
||||
isBranch: false,
|
||||
currentState: TreeNodeCheckState.Checked,
|
||||
@@ -73,14 +77,17 @@ describe('useCollectionSelectionStateUpdater', () => {
|
||||
returnObject.updateNodeSelection(mockEvent);
|
||||
// assert
|
||||
expect(useSelectionStateStub.isSelectionModified()).to.equal(true);
|
||||
expect(selectionStub.isScriptAdded(node.id)).to.equal(true);
|
||||
expect(scriptSelectionStub.isScriptSelected(node.id, false)).to.equal(true);
|
||||
});
|
||||
it('does nothing if already selected', () => {
|
||||
// arrange
|
||||
const { returnObject, useSelectionStateStub } = runHook();
|
||||
const selectionStub = new UserSelectionStub([]);
|
||||
selectionStub.isSelected = () => true;
|
||||
useSelectionStateStub.withUserSelection(selectionStub);
|
||||
const isScriptInitiallySelected = true;
|
||||
const scriptSelectionStub = new ScriptSelectionStub()
|
||||
.withIsSelectedResult(isScriptInitiallySelected);
|
||||
useSelectionStateStub.withUserSelection(
|
||||
new UserSelectionStub().withScripts(scriptSelectionStub),
|
||||
);
|
||||
const mockEvent = new TreeNodeStateChangedEmittedEventStub()
|
||||
.withNode(
|
||||
createTreeNodeStub({
|
||||
@@ -102,9 +109,12 @@ describe('useCollectionSelectionStateUpdater', () => {
|
||||
it('removes from selection if already selected', () => {
|
||||
// arrange
|
||||
const { returnObject, useSelectionStateStub } = runHook();
|
||||
const selectionStub = new UserSelectionStub([]);
|
||||
selectionStub.isSelected = () => true;
|
||||
useSelectionStateStub.withUserSelection(selectionStub);
|
||||
const isScriptInitiallySelected = true;
|
||||
const scriptSelectionStub = new ScriptSelectionStub()
|
||||
.withIsSelectedResult(isScriptInitiallySelected);
|
||||
useSelectionStateStub.withUserSelection(
|
||||
new UserSelectionStub().withScripts(scriptSelectionStub),
|
||||
);
|
||||
const node = createTreeNodeStub({
|
||||
isBranch: false,
|
||||
currentState: TreeNodeCheckState.Unchecked,
|
||||
@@ -119,14 +129,17 @@ describe('useCollectionSelectionStateUpdater', () => {
|
||||
returnObject.updateNodeSelection(mockEvent);
|
||||
// assert
|
||||
expect(useSelectionStateStub.isSelectionModified()).to.equal(true);
|
||||
expect(selectionStub.isScriptRemoved(node.id)).to.equal(true);
|
||||
expect(scriptSelectionStub.isScriptDeselected(node.id)).to.equal(true);
|
||||
});
|
||||
it('does nothing if not already selected', () => {
|
||||
// arrange
|
||||
const { returnObject, useSelectionStateStub } = runHook();
|
||||
const selectionStub = new UserSelectionStub([]);
|
||||
selectionStub.isSelected = () => false;
|
||||
useSelectionStateStub.withUserSelection(selectionStub);
|
||||
const isScriptInitiallySelected = false;
|
||||
const scriptSelectionStub = new ScriptSelectionStub()
|
||||
.withIsSelectedResult(isScriptInitiallySelected);
|
||||
useSelectionStateStub.withUserSelection(
|
||||
new UserSelectionStub().withScripts(scriptSelectionStub),
|
||||
);
|
||||
const mockEvent = new TreeNodeStateChangedEmittedEventStub()
|
||||
.withNode(
|
||||
createTreeNodeStub({
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub'
|
||||
import { getScriptNodeId } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter';
|
||||
import { IScript } from '@/domain/IScript';
|
||||
import { UseUserSelectionStateStub } from '@tests/unit/shared/Stubs/UseUserSelectionStateStub';
|
||||
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
|
||||
|
||||
describe('useSelectedScriptNodeIds', () => {
|
||||
it('returns an empty array when no scripts are selected', () => {
|
||||
@@ -19,8 +20,8 @@ describe('useSelectedScriptNodeIds', () => {
|
||||
it('immediately', () => {
|
||||
// arrange
|
||||
const selectedScripts = [
|
||||
new SelectedScriptStub('id-1'),
|
||||
new SelectedScriptStub('id-2'),
|
||||
new SelectedScriptStub(new ScriptStub('id-1')),
|
||||
new SelectedScriptStub(new ScriptStub('id-2')),
|
||||
];
|
||||
const parsedNodeIds = new Map<IScript, string>([
|
||||
[selectedScripts[0].script, 'expected-id-1'],
|
||||
@@ -43,8 +44,8 @@ describe('useSelectedScriptNodeIds', () => {
|
||||
// arrange
|
||||
const initialScripts = [];
|
||||
const changedScripts = [
|
||||
new SelectedScriptStub('id-1'),
|
||||
new SelectedScriptStub('id-2'),
|
||||
new SelectedScriptStub(new ScriptStub('id-1')),
|
||||
new SelectedScriptStub(new ScriptStub('id-2')),
|
||||
];
|
||||
const parsedNodeIds = new Map<IScript, string>([
|
||||
[changedScripts[0].script, 'expected-id-1'],
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { nextTick, watch } from 'vue';
|
||||
import { SelectionModifier, useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
|
||||
import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
|
||||
import { UseCollectionStateStub } from '@tests/unit/shared/Stubs/UseCollectionStateStub';
|
||||
import { UseAutoUnsubscribedEventsStub } from '@tests/unit/shared/Stubs/UseAutoUnsubscribedEventsStub';
|
||||
import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub';
|
||||
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
|
||||
import { CategoryCollectionStateStub } from '@tests/unit/shared/Stubs/CategoryCollectionStateStub';
|
||||
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
|
||||
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
|
||||
import { ScriptSelectionStub } from '@tests/unit/shared/Stubs/ScriptSelectionStub';
|
||||
|
||||
describe('useUserSelectionState', () => {
|
||||
describe('currentSelection', () => {
|
||||
it('initializes with correct selection', () => {
|
||||
// arrange
|
||||
const expectedSelection = new UserSelectionStub([new ScriptStub('initialSelectedScript')]);
|
||||
const expectedSelection = new UserSelectionStub();
|
||||
const collectionStateStub = new UseCollectionStateStub()
|
||||
.withState(new CategoryCollectionStateStub().withSelection(expectedSelection));
|
||||
// act
|
||||
@@ -27,8 +28,8 @@ describe('useUserSelectionState', () => {
|
||||
describe('once collection state is changed', () => {
|
||||
it('updated', () => {
|
||||
// arrange
|
||||
const initialSelection = new UserSelectionStub([new ScriptStub('initialSelectedScript')]);
|
||||
const changedSelection = new UserSelectionStub([new ScriptStub('changedSelectedScript')]);
|
||||
const initialSelection = new UserSelectionStub();
|
||||
const changedSelection = new UserSelectionStub();
|
||||
const collectionStateStub = new UseCollectionStateStub()
|
||||
.withState(new CategoryCollectionStateStub().withSelection(initialSelection));
|
||||
const { returnObject } = runHook({
|
||||
@@ -45,8 +46,9 @@ describe('useUserSelectionState', () => {
|
||||
});
|
||||
it('not updated when old state changes', async () => {
|
||||
// arrange
|
||||
const oldSelectionState = new UserSelectionStub([new ScriptStub('inOldState')]);
|
||||
const newSelectionState = new UserSelectionStub([new ScriptStub('inNewState')]);
|
||||
const oldScriptSelection = new ScriptSelectionStub();
|
||||
const oldSelectionState = new UserSelectionStub().withScripts(oldScriptSelection);
|
||||
const newSelectionState = new UserSelectionStub();
|
||||
const collectionStateStub = new UseCollectionStateStub()
|
||||
.withState(new CategoryCollectionStateStub().withSelection(oldSelectionState));
|
||||
const { returnObject } = runHook({
|
||||
@@ -61,7 +63,9 @@ describe('useUserSelectionState', () => {
|
||||
totalUpdates++;
|
||||
});
|
||||
// act
|
||||
oldSelectionState.triggerSelectionChangedEvent([new SelectedScriptStub('newInOldState')]);
|
||||
oldScriptSelection.triggerSelectionChangedEvent([
|
||||
new SelectedScriptStub(new ScriptStub('newInOldState')),
|
||||
]);
|
||||
await nextTick();
|
||||
// assert
|
||||
expect(totalUpdates).to.equal(0);
|
||||
@@ -69,8 +73,8 @@ describe('useUserSelectionState', () => {
|
||||
describe('triggers change', () => {
|
||||
it('with new selection reference', async () => {
|
||||
// arrange
|
||||
const oldSelection = new UserSelectionStub([]);
|
||||
const newSelection = new UserSelectionStub([]);
|
||||
const oldSelection = new UserSelectionStub();
|
||||
const newSelection = new UserSelectionStub();
|
||||
const initialCollectionState = new CategoryCollectionStateStub()
|
||||
.withSelection(oldSelection);
|
||||
const changedCollectionState = new CategoryCollectionStateStub()
|
||||
@@ -93,7 +97,7 @@ describe('useUserSelectionState', () => {
|
||||
});
|
||||
it('with the same selection reference', async () => {
|
||||
// arrange
|
||||
const userSelection = new UserSelectionStub([new ScriptStub('sameScriptInSameReference')]);
|
||||
const userSelection = new UserSelectionStub();
|
||||
const initialCollectionState = new CategoryCollectionStateStub()
|
||||
.withSelection(userSelection);
|
||||
const changedCollectionState = new CategoryCollectionStateStub()
|
||||
@@ -119,26 +123,38 @@ describe('useUserSelectionState', () => {
|
||||
describe('once selection state is changed', () => {
|
||||
it('updated with same collection state', async () => {
|
||||
// arrange
|
||||
const initialScripts = [new ScriptStub('initialSelectedScript')];
|
||||
const changedScripts = [new SelectedScriptStub('changedSelectedScript')];
|
||||
const selectionState = new UserSelectionStub(initialScripts);
|
||||
const collectionState = new CategoryCollectionStateStub().withSelection(selectionState);
|
||||
const initialScripts = [
|
||||
new SelectedScriptStub(new ScriptStub('initialSelectedScript')),
|
||||
];
|
||||
const changedScripts = [
|
||||
new SelectedScriptStub(new ScriptStub('changedSelectedScript')),
|
||||
];
|
||||
const scriptSelectionStub = new ScriptSelectionStub()
|
||||
.withSelectedScripts(initialScripts);
|
||||
const expectedSelectionState = new UserSelectionStub().withScripts(scriptSelectionStub);
|
||||
const collectionState = new CategoryCollectionStateStub()
|
||||
.withSelection(expectedSelectionState);
|
||||
const collectionStateStub = new UseCollectionStateStub().withState(collectionState);
|
||||
const { returnObject } = runHook({
|
||||
useCollectionState: collectionStateStub,
|
||||
});
|
||||
// act
|
||||
selectionState.triggerSelectionChangedEvent(changedScripts);
|
||||
scriptSelectionStub.triggerSelectionChangedEvent(changedScripts);
|
||||
await nextTick();
|
||||
// assert
|
||||
const actualSelection = returnObject.currentSelection.value;
|
||||
expect(actualSelection).to.equal(selectionState);
|
||||
expect(actualSelection).to.equal(expectedSelectionState);
|
||||
});
|
||||
it('updated once collection state is changed', async () => {
|
||||
// arrange
|
||||
const changedScripts = [new SelectedScriptStub('changedSelectedScript')];
|
||||
const newSelectionState = new UserSelectionStub([new ScriptStub('initialSelectedScriptInNewCollection')]);
|
||||
const initialCollectionState = new CategoryCollectionStateStub().withSelectedScripts([new SelectedScriptStub('initialSelectedScriptInInitialCollection')]);
|
||||
const changedScripts = [
|
||||
new SelectedScriptStub(new ScriptStub('changedSelectedScript')),
|
||||
];
|
||||
const scriptSelectionStub = new ScriptSelectionStub();
|
||||
const newSelectionState = new UserSelectionStub().withScripts(scriptSelectionStub);
|
||||
const initialCollectionState = new CategoryCollectionStateStub().withSelectedScripts([
|
||||
new SelectedScriptStub(new ScriptStub('initialSelectedScriptInInitialCollection')),
|
||||
]);
|
||||
const collectionStateStub = new UseCollectionStateStub().withState(initialCollectionState);
|
||||
const { returnObject } = runHook({ useCollectionState: collectionStateStub });
|
||||
// act
|
||||
@@ -146,7 +162,7 @@ describe('useUserSelectionState', () => {
|
||||
newState: new CategoryCollectionStateStub().withSelection(newSelectionState),
|
||||
immediateOnly: false,
|
||||
});
|
||||
newSelectionState.triggerSelectionChangedEvent(changedScripts);
|
||||
scriptSelectionStub.triggerSelectionChangedEvent(changedScripts);
|
||||
// assert
|
||||
const actualSelection = returnObject.currentSelection.value;
|
||||
expect(actualSelection).to.equal(newSelectionState);
|
||||
@@ -156,17 +172,19 @@ describe('useUserSelectionState', () => {
|
||||
// arrange
|
||||
const oldSelectedScriptsArrayReference = [];
|
||||
const newSelectedScriptsArrayReference = [];
|
||||
const userSelection = new UserSelectionStub(oldSelectedScriptsArrayReference)
|
||||
const scriptSelectionStub = new ScriptSelectionStub()
|
||||
.withSelectedScripts(oldSelectedScriptsArrayReference);
|
||||
const collectionStateStub = new UseCollectionStateStub()
|
||||
.withState(new CategoryCollectionStateStub().withSelection(userSelection));
|
||||
.withState(new CategoryCollectionStateStub().withSelection(
|
||||
new UserSelectionStub().withScripts(scriptSelectionStub),
|
||||
));
|
||||
const { returnObject } = runHook({ useCollectionState: collectionStateStub });
|
||||
let isChangeTriggered = false;
|
||||
watch(returnObject.currentSelection, () => {
|
||||
isChangeTriggered = true;
|
||||
});
|
||||
// act
|
||||
userSelection.triggerSelectionChangedEvent(newSelectedScriptsArrayReference);
|
||||
scriptSelectionStub.triggerSelectionChangedEvent(newSelectedScriptsArrayReference);
|
||||
await nextTick();
|
||||
// assert
|
||||
expect(isChangeTriggered).to.equal(true);
|
||||
@@ -174,17 +192,19 @@ describe('useUserSelectionState', () => {
|
||||
it('with same selected scripts array reference', async () => {
|
||||
// arrange
|
||||
const sharedSelectedScriptsReference = [];
|
||||
const userSelection = new UserSelectionStub([])
|
||||
const scriptSelectionStub = new ScriptSelectionStub()
|
||||
.withSelectedScripts(sharedSelectedScriptsReference);
|
||||
const collectionStateStub = new UseCollectionStateStub()
|
||||
.withState(new CategoryCollectionStateStub().withSelection(userSelection));
|
||||
.withState(new CategoryCollectionStateStub().withSelection(
|
||||
new UserSelectionStub().withScripts(scriptSelectionStub),
|
||||
));
|
||||
const { returnObject } = runHook({ useCollectionState: collectionStateStub });
|
||||
let isChangeTriggered = false;
|
||||
watch(returnObject.currentSelection, () => {
|
||||
isChangeTriggered = true;
|
||||
});
|
||||
// act
|
||||
userSelection.triggerSelectionChangedEvent(sharedSelectedScriptsReference);
|
||||
scriptSelectionStub.triggerSelectionChangedEvent(sharedSelectedScriptsReference);
|
||||
await nextTick();
|
||||
// assert
|
||||
expect(isChangeTriggered).to.equal(true);
|
||||
@@ -197,7 +217,7 @@ describe('useUserSelectionState', () => {
|
||||
// arrange
|
||||
const { returnObject, collectionStateStub } = runHook();
|
||||
const expectedSelection = collectionStateStub.state.selection;
|
||||
let mutatedSelection: IUserSelection | undefined;
|
||||
let mutatedSelection: UserSelection | undefined;
|
||||
const mutator: SelectionModifier = (selection) => {
|
||||
mutatedSelection = selection;
|
||||
};
|
||||
@@ -210,10 +230,10 @@ describe('useUserSelectionState', () => {
|
||||
it('new state is modified once collection state is changed', async () => {
|
||||
// arrange
|
||||
const { returnObject, collectionStateStub } = runHook();
|
||||
const expectedSelection = new UserSelectionStub([]);
|
||||
const expectedSelection = new UserSelectionStub();
|
||||
const newCollectionState = new CategoryCollectionStateStub()
|
||||
.withSelection(expectedSelection);
|
||||
let mutatedSelection: IUserSelection | undefined;
|
||||
let mutatedSelection: UserSelection | undefined;
|
||||
const mutator: SelectionModifier = (selection) => {
|
||||
mutatedSelection = selection;
|
||||
};
|
||||
@@ -231,12 +251,12 @@ describe('useUserSelectionState', () => {
|
||||
it('old state is not modified once collection state is changed', async () => {
|
||||
// arrange
|
||||
const oldState = new CategoryCollectionStateStub().withSelectedScripts([
|
||||
new SelectedScriptStub('scriptFromOldState'),
|
||||
new SelectedScriptStub(new ScriptStub('scriptFromOldState')),
|
||||
]);
|
||||
const collectionStateStub = new UseCollectionStateStub()
|
||||
.withState(oldState);
|
||||
const { returnObject } = runHook({ useCollectionState: collectionStateStub });
|
||||
const expectedSelection = new UserSelectionStub([]);
|
||||
const expectedSelection = new UserSelectionStub();
|
||||
const newCollectionState = new CategoryCollectionStateStub()
|
||||
.withSelection(expectedSelection);
|
||||
let totalMutations = 0;
|
||||
|
||||
33
tests/unit/shared/Stubs/BatchedDebounceStub.ts
Normal file
33
tests/unit/shared/Stubs/BatchedDebounceStub.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,17 @@ import { IApplicationCode } from '@/application/Context/State/Code/IApplicationC
|
||||
import { IUserFilter } from '@/application/Context/State/Filter/IUserFilter';
|
||||
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import { IScript } from '@/domain/IScript';
|
||||
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
|
||||
import { ICategoryCollection } from '@/domain/ICategoryCollection';
|
||||
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
|
||||
import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
|
||||
import { CategoryCollectionStub } from './CategoryCollectionStub';
|
||||
import { UserSelectionStub } from './UserSelectionStub';
|
||||
import { UserFilterStub } from './UserFilterStub';
|
||||
import { ApplicationCodeStub } from './ApplicationCodeStub';
|
||||
import { CategoryStub } from './CategoryStub';
|
||||
import { ScriptSelectionStub } from './ScriptSelectionStub';
|
||||
|
||||
export class CategoryCollectionStateStub implements ICategoryCollectionState {
|
||||
public code: IApplicationCode = new ApplicationCodeStub();
|
||||
@@ -24,10 +25,11 @@ export class CategoryCollectionStateStub implements ICategoryCollectionState {
|
||||
|
||||
public collection: ICategoryCollection = new CategoryCollectionStub().withSomeActions();
|
||||
|
||||
public selection: IUserSelection = new UserSelectionStub([]);
|
||||
public selection: UserSelection = new UserSelectionStub();
|
||||
|
||||
constructor(readonly allScripts: IScript[] = [new ScriptStub('script-id')]) {
|
||||
this.selection = new UserSelectionStub(allScripts);
|
||||
this.selection = new UserSelectionStub()
|
||||
.withScripts(new ScriptSelectionStub());
|
||||
this.collection = new CategoryCollectionStub()
|
||||
.withOs(this.os)
|
||||
.withTotalScripts(this.allScripts.length)
|
||||
@@ -60,11 +62,14 @@ export class CategoryCollectionStateStub implements ICategoryCollectionState {
|
||||
|
||||
public withSelectedScripts(initialScripts: readonly SelectedScript[]): this {
|
||||
return this.withSelection(
|
||||
new UserSelectionStub([]).withSelectedScripts(initialScripts),
|
||||
new UserSelectionStub().withScripts(
|
||||
new ScriptSelectionStub()
|
||||
.withSelectedScripts(initialScripts),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public withSelection(selection: IUserSelection) {
|
||||
public withSelection(selection: UserSelection) {
|
||||
this.selection = selection;
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,13 @@ export class CategoryCollectionStub implements ICategoryCollection {
|
||||
return this;
|
||||
}
|
||||
|
||||
public withActions(...actions: readonly ICategory[]): this {
|
||||
for (const action of actions) {
|
||||
this.withAction(action);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public withOs(os: OperatingSystem): this {
|
||||
this.os = os;
|
||||
return this;
|
||||
|
||||
33
tests/unit/shared/Stubs/CategorySelectionStub.ts
Normal file
33
tests/unit/shared/Stubs/CategorySelectionStub.ts
Normal 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],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export class CategoryStub extends BaseEntity<number> implements ICategory {
|
||||
|
||||
public readonly docs = new Array<string>();
|
||||
|
||||
private allScriptsRecursively: (readonly IScript[]) | undefined;
|
||||
|
||||
public constructor(id: number) {
|
||||
super(id);
|
||||
}
|
||||
@@ -21,13 +23,16 @@ export class CategoryStub extends BaseEntity<number> implements ICategory {
|
||||
}
|
||||
|
||||
public getAllScriptsRecursively(): readonly IScript[] {
|
||||
return [
|
||||
...this.scripts,
|
||||
...this.subCategories.flatMap((c) => c.getAllScriptsRecursively()),
|
||||
];
|
||||
if (this.allScriptsRecursively === undefined) {
|
||||
return [
|
||||
...this.scripts,
|
||||
...this.subCategories.flatMap((c) => c.getAllScriptsRecursively()),
|
||||
];
|
||||
}
|
||||
return this.allScriptsRecursively;
|
||||
}
|
||||
|
||||
public withScriptIds(...scriptIds: string[]): this {
|
||||
public withScriptIds(...scriptIds: readonly string[]): this {
|
||||
return this.withScripts(
|
||||
...scriptIds.map((id) => new ScriptStub(id)),
|
||||
);
|
||||
@@ -40,6 +45,15 @@ export class CategoryStub extends BaseEntity<number> implements ICategory {
|
||||
return this;
|
||||
}
|
||||
|
||||
public withAllScriptIdsRecursively(...scriptIds: readonly string[]): this {
|
||||
return this.withAllScriptsRecursively(...scriptIds.map((id) => new ScriptStub(id)));
|
||||
}
|
||||
|
||||
public withAllScriptsRecursively(...scripts: IScript[]): this {
|
||||
this.allScriptsRecursively = [...scripts];
|
||||
return this;
|
||||
}
|
||||
|
||||
public withMandatoryScripts(): this {
|
||||
return this
|
||||
.withScript(new ScriptStub(`[${CategoryStub.name}] script-1`).withLevel(RecommendationLevel.Standard))
|
||||
|
||||
89
tests/unit/shared/Stubs/ScriptSelectionStub.ts
Normal file
89
tests/unit/shared/Stubs/ScriptSelectionStub.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { BaseEntity } from '@/infrastructure/Entity/BaseEntity';
|
||||
import { IScript } from '@/domain/IScript';
|
||||
import { RecommendationLevel } from '@/domain/RecommendationLevel';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import { IScriptCode } from '@/domain/IScriptCode';
|
||||
import { SelectedScriptStub } from './SelectedScriptStub';
|
||||
|
||||
export class ScriptStub extends BaseEntity<string> implements IScript {
|
||||
public name = `name${this.id}`;
|
||||
@@ -50,7 +50,7 @@ export class ScriptStub extends BaseEntity<string> implements IScript {
|
||||
return this;
|
||||
}
|
||||
|
||||
public toSelectedScript(isReverted = false): SelectedScript {
|
||||
return new SelectedScript(this, isReverted);
|
||||
public toSelectedScript(): SelectedScriptStub {
|
||||
return new SelectedScriptStub(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import { ScriptStub } from './ScriptStub';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
|
||||
import { IScript } from '@/domain/IScript';
|
||||
|
||||
export class SelectedScriptStub extends SelectedScript {
|
||||
constructor(id: string, revert = false) {
|
||||
super(new ScriptStub(id), revert);
|
||||
export class SelectedScriptStub implements SelectedScript {
|
||||
public readonly script: IScript;
|
||||
|
||||
public readonly id: string;
|
||||
|
||||
public revert: boolean;
|
||||
|
||||
constructor(
|
||||
script: IScript,
|
||||
) {
|
||||
this.id = script.id;
|
||||
this.script = script;
|
||||
}
|
||||
|
||||
public withRevert(revert: boolean): this {
|
||||
this.revert = revert;
|
||||
return this;
|
||||
}
|
||||
|
||||
public equals(): boolean {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export abstract class StubWithObservableMethodCalls<T> {
|
||||
}
|
||||
}
|
||||
|
||||
type MethodCall<T> = {
|
||||
export type MethodCall<T> = {
|
||||
[K in FunctionKeys<T>]: {
|
||||
readonly methodName: K;
|
||||
readonly args: T[K] extends (...args: infer A) => unknown ? A : never;
|
||||
|
||||
42
tests/unit/shared/Stubs/TimerStub.ts
Normal file
42
tests/unit/shared/Stubs/TimerStub.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import { shallowRef } from 'vue';
|
||||
import type { SelectionModifier, useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
|
||||
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
|
||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||
import { UserSelectionStub } from './UserSelectionStub';
|
||||
import { ScriptSelectionStub } from './ScriptSelectionStub';
|
||||
|
||||
export class UseUserSelectionStateStub
|
||||
extends StubWithObservableMethodCalls<ReturnType<typeof useUserSelectionState>> {
|
||||
private readonly currentSelection = shallowRef<IUserSelection>(
|
||||
new UserSelectionStub([]),
|
||||
private readonly currentSelection = shallowRef<UserSelection>(
|
||||
new UserSelectionStub(),
|
||||
);
|
||||
|
||||
private modifyCurrentSelection(mutator: SelectionModifier) {
|
||||
@@ -19,19 +20,21 @@ export class UseUserSelectionStateStub
|
||||
});
|
||||
}
|
||||
|
||||
public withUserSelection(userSelection: IUserSelection): this {
|
||||
public withUserSelection(userSelection: UserSelection): this {
|
||||
this.currentSelection.value = userSelection;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withSelectedScripts(selectedScripts: readonly SelectedScript[]): this {
|
||||
return this.withUserSelection(
|
||||
new UserSelectionStub(selectedScripts.map((s) => s.script))
|
||||
.withSelectedScripts(selectedScripts),
|
||||
new UserSelectionStub()
|
||||
.withScripts(
|
||||
new ScriptSelectionStub().withSelectedScripts(selectedScripts),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public get selection(): IUserSelection {
|
||||
public get selection(): UserSelection {
|
||||
return this.currentSelection.value;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,91 +1,21 @@
|
||||
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import { IScript } from '@/domain/IScript';
|
||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||
import { EventSourceStub } from './EventSourceStub';
|
||||
import { CategorySelection } from '@/application/Context/State/Selection/Category/CategorySelection';
|
||||
import { ScriptSelection } from '@/application/Context/State/Selection/Script/ScriptSelection';
|
||||
import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
|
||||
import { CategorySelectionStub } from './CategorySelectionStub';
|
||||
import { ScriptSelectionStub } from './ScriptSelectionStub';
|
||||
|
||||
export class UserSelectionStub
|
||||
extends StubWithObservableMethodCalls<IUserSelection>
|
||||
implements IUserSelection {
|
||||
public readonly changed = new EventSourceStub<readonly SelectedScript[]>();
|
||||
export class UserSelectionStub implements UserSelection {
|
||||
public categories: CategorySelection = new CategorySelectionStub();
|
||||
|
||||
public selectedScripts: readonly SelectedScript[] = [];
|
||||
public scripts: ScriptSelection = new ScriptSelectionStub();
|
||||
|
||||
constructor(private readonly allScripts: readonly IScript[]) {
|
||||
super();
|
||||
}
|
||||
|
||||
public withSelectedScripts(selectedScripts: readonly SelectedScript[]): this {
|
||||
this.selectedScripts = selectedScripts;
|
||||
public withCategories(categories: CategorySelection): this {
|
||||
this.categories = categories;
|
||||
return this;
|
||||
}
|
||||
|
||||
public triggerSelectionChangedEvent(scripts: readonly SelectedScript[]): this {
|
||||
this.changed.notify(scripts);
|
||||
public withScripts(scripts: ScriptSelection): this {
|
||||
this.scripts = scripts;
|
||||
return this;
|
||||
}
|
||||
|
||||
public isScriptAdded(scriptId: string): boolean {
|
||||
const call = this.callHistory.find(
|
||||
(c) => c.methodName === 'addSelectedScript' && c.args[0] === scriptId,
|
||||
);
|
||||
return call !== undefined;
|
||||
}
|
||||
|
||||
public isScriptRemoved(scriptId: string): boolean {
|
||||
const call = this.callHistory.find(
|
||||
(c) => c.methodName === 'removeSelectedScript' && c.args[0] === scriptId,
|
||||
);
|
||||
return call !== undefined;
|
||||
}
|
||||
|
||||
public areAllSelected(): boolean {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
public isAnySelected(): boolean {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
public removeAllInCategory(): void {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
public addOrUpdateAllInCategory(): void {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
public addSelectedScript(scriptId: string, revert: boolean): void {
|
||||
this.registerMethodCall({
|
||||
methodName: 'addSelectedScript',
|
||||
args: [scriptId, revert],
|
||||
});
|
||||
}
|
||||
|
||||
public addOrUpdateSelectedScript(): void {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
public removeSelectedScript(scriptId: string): void {
|
||||
this.registerMethodCall({
|
||||
methodName: 'removeSelectedScript',
|
||||
args: [scriptId],
|
||||
});
|
||||
}
|
||||
|
||||
public selectOnly(scripts: ReadonlyArray<IScript>): void {
|
||||
this.selectedScripts = scripts.map((s) => new SelectedScript(s, false));
|
||||
}
|
||||
|
||||
public isSelected(): boolean {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
public selectAll(): void {
|
||||
this.selectOnly(this.allScripts);
|
||||
}
|
||||
|
||||
public deselectAll(): void {
|
||||
this.selectedScripts = [];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user