Skip to content

Commit

Permalink
Fix code highlighting and optimize category select
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
undergroundwires committed Nov 18, 2023
1 parent 4531645 commit cb42f11
Show file tree
Hide file tree
Showing 79 changed files with 2,729 additions and 1,347 deletions.
4 changes: 3 additions & 1 deletion docs/tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 27 additions & 0 deletions src/application/Common/Timing/BatchedDebounce.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 7 additions & 0 deletions src/application/Common/Timing/PlatformTimer.ts
Original file line number Diff line number Diff line change
@@ -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(),
};
Original file line number Diff line number Diff line change
@@ -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,
) {
Expand Down
8 changes: 8 additions & 0 deletions src/application/Common/Timing/Timer.ts
Original file line number Diff line number Diff line change
@@ -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;
}
37 changes: 31 additions & 6 deletions src/application/Context/State/CategoryCollectionState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
10 changes: 5 additions & 5 deletions src/application/Context/State/Code/ApplicationCode.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);
});
}
Expand Down
11 changes: 9 additions & 2 deletions src/application/Context/State/Code/Event/CodeChangedEvent.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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');
}
Expand Down
6 changes: 3 additions & 3 deletions src/application/Context/State/Code/Event/ICodeChangedEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
6 changes: 3 additions & 3 deletions src/application/Context/State/Code/Generation/IUserScript.ts
Original file line number Diff line number Diff line change
@@ -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>;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
6 changes: 3 additions & 3 deletions src/application/Context/State/ICategoryCollectionState.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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[];
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
23 changes: 0 additions & 23 deletions src/application/Context/State/Selection/IUserSelection.ts

This file was deleted.

Loading

0 comments on commit cb42f11

Please sign in to comment.