Fix code highlighting and optimize category select

This commit introduces a batched debounce mechanism for managing user
selection state changes. It effectively reduces unnecessary processing
during rapid script checking, preventing multiple triggers for code
compilation and UI rendering.

Key improvements include:

- Enhanced performance, especially noticeable when selecting large
  categories. This update resolves minor UI freezes experienced when
  selecting categories with numerous scripts.
- Correction of a bug where the code area only highlighted the last
  selected script when multiple scripts were chosen.

Other changes include:

- Timing functions:
  - Create a `Timing` folder for `throttle` and the new
    `batchedDebounce` functions.
  - Move these functions to the application layer from the presentation
    layer, reflecting their application-wide use.
  - Refactor existing code for improved clarity, naming consistency, and
    adherence to new naming conventions.
  - Add missing unit tests.
- `UserSelection`:
  - State modifications in `UserSelection` now utilize a singular object
    inspired by the CQRS pattern, enabling batch updates and flexible
    change configurations, thereby simplifying change management.
- Remove the `I` prefix from related interfaces to align with new coding
  standards.
- Refactor related code for better testability in isolation with
  dependency injection.
- Repository:
  - Move repository abstractions to the application layer.
  - Improve repository abstraction to combine `ReadonlyRepository` and
    `MutableRepository` interfaces.
- E2E testing:
  - Introduce E2E tests to validate the correct batch selection
    behavior.
  - Add a specialized data attribute in `TheCodeArea.vue` for improved
    testability.
  - Reorganize shared Cypress functions for a more idiomatic Cypress
    approach.
  - Improve test documentation with related information.
- `SelectedScript`:
  - Create an abstraction for simplified testability.
  - Introduce `SelectedScriptStub` in tests as a substitute for the
    actual object.
This commit is contained in:
undergroundwires
2023-11-18 22:23:27 +01:00
parent 4531645b4c
commit cb42f11b97
79 changed files with 2733 additions and 1351 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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