Refactor executable IDs to use strings #262

This commit unifies executable ID structure across categories and
scripts, paving the way for more complex ID solutions for #262.
It also refactors related code to adapt to the changes.

Key changes:

- Change numeric IDs to string IDs for categories
- Use named types for string IDs to improve code clarity
- Add unit tests to verify ID uniqueness

Other supporting changes:

- Separate concerns in entities for data access and executables by using
  separate abstractions (`Identifiable` and `RepositoryEntity`)
- Simplify usage and construction of entities.
- Remove `BaseEntity` for simplicity.
- Move creation of categories/scripts to domain layer
- Refactor CategoryCollection for better validation logic isolation
- Rename some categories to keep the names (used as pseudo-IDs) unique
  on Windows.
This commit is contained in:
undergroundwires
2024-08-03 16:54:14 +02:00
parent 6fbc81675f
commit ded55a66d6
124 changed files with 2286 additions and 1331 deletions

View File

@@ -30,6 +30,8 @@ Related documentation:
### Executables ### Executables
They represent independently executable actions with documentation and reversibility.
An Executable is a logical entity that can An Executable is a logical entity that can
- execute once compiled, - execute once compiled,

View File

@@ -1,6 +1,6 @@
import type { IApplication } from '@/domain/IApplication'; import type { IApplication } from '@/domain/IApplication';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { EventSource } from '@/infrastructure/Events/EventSource'; import { EventSource } from '@/infrastructure/Events/EventSource';
import { assertInRange } from '@/application/Common/Enum'; import { assertInRange } from '@/application/Common/Enum';
import { CategoryCollectionState } from './State/CategoryCollectionState'; import { CategoryCollectionState } from './State/CategoryCollectionState';

View File

@@ -1,4 +1,4 @@
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { AdaptiveFilterContext } from './Filter/AdaptiveFilterContext'; import { AdaptiveFilterContext } from './Filter/AdaptiveFilterContext';
import { ApplicationCode } from './Code/ApplicationCode'; import { ApplicationCode } from './Code/ApplicationCode';

View File

@@ -2,6 +2,7 @@ import type { Script } from '@/domain/Executables/Script/Script';
import type { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition'; import type { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript'; import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines'; import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import type { ICodeChangedEvent } from './ICodeChangedEvent'; import type { ICodeChangedEvent } from './ICodeChangedEvent';
export class CodeChangedEvent implements ICodeChangedEvent { export class CodeChangedEvent implements ICodeChangedEvent {
@@ -37,12 +38,12 @@ export class CodeChangedEvent implements ICodeChangedEvent {
} }
public getScriptPositionInCode(script: Script): ICodePosition { public getScriptPositionInCode(script: Script): ICodePosition {
return this.getPositionById(script.id); return this.getPositionById(script.executableId);
} }
private getPositionById(scriptId: string): ICodePosition { private getPositionById(scriptId: ExecutableId): ICodePosition {
const position = [...this.scripts.entries()] const position = [...this.scripts.entries()]
.filter(([s]) => s.id === scriptId) .filter(([s]) => s.executableId === scriptId)
.map(([, pos]) => pos) .map(([, pos]) => pos)
.at(0); .at(0);
if (!position) { if (!position) {

View File

@@ -1,5 +1,5 @@
import { EventSource } from '@/infrastructure/Events/EventSource'; import { EventSource } from '@/infrastructure/Events/EventSource';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { FilterChange } from './Event/FilterChange'; import { FilterChange } from './Event/FilterChange';
import { LinearFilterStrategy } from './Strategy/LinearFilterStrategy'; import { LinearFilterStrategy } from './Strategy/LinearFilterStrategy';
import type { FilterResult } from './Result/FilterResult'; import type { FilterResult } from './Result/FilterResult';

View File

@@ -1,4 +1,4 @@
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import type { FilterResult } from '../Result/FilterResult'; import type { FilterResult } from '../Result/FilterResult';
export interface FilterStrategy { export interface FilterStrategy {

View File

@@ -1,7 +1,7 @@
import type { Category } from '@/domain/Executables/Category/Category'; import type { Category } from '@/domain/Executables/Category/Category';
import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode'; import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode';
import type { Documentable } from '@/domain/Executables/Documentable'; import type { Documentable } from '@/domain/Executables/Documentable';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import type { Script } from '@/domain/Executables/Script/Script'; import type { Script } from '@/domain/Executables/Script/Script';
import { AppliedFilterResult } from '../Result/AppliedFilterResult'; import { AppliedFilterResult } from '../Result/AppliedFilterResult';
import type { FilterStrategy } from './FilterStrategy'; import type { FilterStrategy } from './FilterStrategy';

View File

@@ -1,4 +1,4 @@
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import type { IApplicationCode } from './Code/IApplicationCode'; import type { IApplicationCode } from './Code/IApplicationCode';
import type { ReadonlyFilterContext, FilterContext } from './Filter/FilterContext'; import type { ReadonlyFilterContext, FilterContext } from './Filter/FilterContext';

View File

@@ -1,3 +1,5 @@
import type { ExecutableId } from '@/domain/Executables/Identifiable';
type CategorySelectionStatus = { type CategorySelectionStatus = {
readonly isSelected: true; readonly isSelected: true;
readonly isReverted: boolean; readonly isReverted: boolean;
@@ -6,7 +8,7 @@ type CategorySelectionStatus = {
}; };
export interface CategorySelectionChange { export interface CategorySelectionChange {
readonly categoryId: number; readonly categoryId: ExecutableId;
readonly newStatus: CategorySelectionStatus; readonly newStatus: CategorySelectionStatus;
} }

View File

@@ -1,5 +1,5 @@
import type { Category } from '@/domain/Executables/Category/Category'; import type { Category } from '@/domain/Executables/Category/Category';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import type { CategorySelectionChange, CategorySelectionChangeCommand } from './CategorySelectionChange'; import type { CategorySelectionChange, CategorySelectionChangeCommand } from './CategorySelectionChange';
import type { CategorySelection } from './CategorySelection'; import type { CategorySelection } from './CategorySelection';
import type { ScriptSelection } from '../Script/ScriptSelection'; import type { ScriptSelection } from '../Script/ScriptSelection';
@@ -23,7 +23,7 @@ export class ScriptToCategorySelectionMapper implements CategorySelection {
return false; return false;
} }
return scripts.every( return scripts.every(
(script) => selectedScripts.some((selected) => selected.id === script.id), (script) => selectedScripts.some((selected) => selected.id === script.executableId),
); );
} }
@@ -50,7 +50,7 @@ export class ScriptToCategorySelectionMapper implements CategorySelection {
const scripts = category.getAllScriptsRecursively(); const scripts = category.getAllScriptsRecursively();
const scriptsChangesInCategory = scripts const scriptsChangesInCategory = scripts
.map((script): ScriptSelectionChange => ({ .map((script): ScriptSelectionChange => ({
scriptId: script.id, scriptId: script.executableId,
newStatus: { newStatus: {
...change.newStatus, ...change.newStatus,
}, },

View File

@@ -2,8 +2,9 @@ import { InMemoryRepository } from '@/infrastructure/Repository/InMemoryReposito
import type { Script } from '@/domain/Executables/Script/Script'; import type { Script } from '@/domain/Executables/Script/Script';
import { EventSource } from '@/infrastructure/Events/EventSource'; import { EventSource } from '@/infrastructure/Events/EventSource';
import type { ReadonlyRepository, Repository } from '@/application/Repository/Repository'; import type { ReadonlyRepository, Repository } from '@/application/Repository/Repository';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { batchedDebounce } from '@/application/Common/Timing/BatchedDebounce'; import { batchedDebounce } from '@/application/Common/Timing/BatchedDebounce';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import { UserSelectedScript } from './UserSelectedScript'; import { UserSelectedScript } from './UserSelectedScript';
import type { ScriptSelection } from './ScriptSelection'; import type { ScriptSelection } from './ScriptSelection';
import type { ScriptSelectionChange, ScriptSelectionChangeCommand } from './ScriptSelectionChange'; import type { ScriptSelectionChange, ScriptSelectionChangeCommand } from './ScriptSelectionChange';
@@ -16,7 +17,7 @@ export type DebounceFunction = typeof batchedDebounce<ScriptSelectionChangeComma
export class DebouncedScriptSelection implements ScriptSelection { export class DebouncedScriptSelection implements ScriptSelection {
public readonly changed = new EventSource<ReadonlyArray<SelectedScript>>(); public readonly changed = new EventSource<ReadonlyArray<SelectedScript>>();
private readonly scripts: Repository<string, SelectedScript>; private readonly scripts: Repository<SelectedScript>;
public readonly processChanges: ScriptSelection['processChanges']; public readonly processChanges: ScriptSelection['processChanges'];
@@ -25,7 +26,7 @@ export class DebouncedScriptSelection implements ScriptSelection {
selectedScripts: ReadonlyArray<SelectedScript>, selectedScripts: ReadonlyArray<SelectedScript>,
debounce: DebounceFunction = batchedDebounce, debounce: DebounceFunction = batchedDebounce,
) { ) {
this.scripts = new InMemoryRepository<string, SelectedScript>(); this.scripts = new InMemoryRepository<SelectedScript>();
for (const script of selectedScripts) { for (const script of selectedScripts) {
this.scripts.addItem(script); this.scripts.addItem(script);
} }
@@ -38,8 +39,8 @@ export class DebouncedScriptSelection implements ScriptSelection {
); );
} }
public isSelected(scriptId: string): boolean { public isSelected(scriptExecutableId: ExecutableId): boolean {
return this.scripts.exists(scriptId); return this.scripts.exists(scriptExecutableId);
} }
public get selectedScripts(): readonly SelectedScript[] { public get selectedScripts(): readonly SelectedScript[] {
@@ -49,7 +50,7 @@ export class DebouncedScriptSelection implements ScriptSelection {
public selectAll(): void { public selectAll(): void {
const scriptsToSelect = this.collection const scriptsToSelect = this.collection
.getAllScripts() .getAllScripts()
.filter((script) => !this.scripts.exists(script.id)) .filter((script) => !this.scripts.exists(script.executableId))
.map((script) => new UserSelectedScript(script, false)); .map((script) => new UserSelectedScript(script, false));
if (scriptsToSelect.length === 0) { if (scriptsToSelect.length === 0) {
return; return;
@@ -116,12 +117,12 @@ export class DebouncedScriptSelection implements ScriptSelection {
private applyChange(change: ScriptSelectionChange): number { private applyChange(change: ScriptSelectionChange): number {
const script = this.collection.getScript(change.scriptId); const script = this.collection.getScript(change.scriptId);
if (change.newStatus.isSelected) { if (change.newStatus.isSelected) {
return this.addOrUpdateScript(script.id, change.newStatus.isReverted); return this.addOrUpdateScript(script.executableId, change.newStatus.isReverted);
} }
return this.removeScript(script.id); return this.removeScript(script.executableId);
} }
private addOrUpdateScript(scriptId: string, revert: boolean): number { private addOrUpdateScript(scriptId: ExecutableId, revert: boolean): number {
const script = this.collection.getScript(scriptId); const script = this.collection.getScript(scriptId);
const selectedScript = new UserSelectedScript(script, revert); const selectedScript = new UserSelectedScript(script, revert);
if (!this.scripts.exists(selectedScript.id)) { if (!this.scripts.exists(selectedScript.id)) {
@@ -136,7 +137,7 @@ export class DebouncedScriptSelection implements ScriptSelection {
return 1; return 1;
} }
private removeScript(scriptId: string): number { private removeScript(scriptId: ExecutableId): number {
if (!this.scripts.exists(scriptId)) { if (!this.scripts.exists(scriptId)) {
return 0; return 0;
} }
@@ -152,24 +153,24 @@ function assertNonEmptyScriptSelection(selectedItems: readonly Script[]) {
} }
function getScriptIdsToBeSelected( function getScriptIdsToBeSelected(
existingItems: ReadonlyRepository<string, SelectedScript>, existingItems: ReadonlyRepository<SelectedScript>,
desiredScripts: readonly Script[], desiredScripts: readonly Script[],
): string[] { ): string[] {
return desiredScripts return desiredScripts
.filter((script) => !existingItems.exists(script.id)) .filter((script) => !existingItems.exists(script.executableId))
.map((script) => script.id); .map((script) => script.executableId);
} }
function getScriptIdsToBeDeselected( function getScriptIdsToBeDeselected(
existingItems: ReadonlyRepository<string, SelectedScript>, existingItems: ReadonlyRepository<SelectedScript>,
desiredScripts: readonly Script[], desiredScripts: readonly Script[],
): string[] { ): string[] {
return existingItems return existingItems
.getItems() .getItems()
.filter((existing) => !desiredScripts.some((script) => existing.id === script.id)) .filter((existing) => !desiredScripts.some((script) => existing.id === script.executableId))
.map((script) => script.id); .map((script) => script.id);
} }
function equals(a: SelectedScript, b: SelectedScript): boolean { function equals(a: SelectedScript, b: SelectedScript): boolean {
return a.script.equals(b.script.id) && a.revert === b.revert; return a.script.executableId === b.script.executableId && a.revert === b.revert;
} }

View File

@@ -1,12 +1,13 @@
import type { IEventSource } from '@/infrastructure/Events/IEventSource'; import type { IEventSource } from '@/infrastructure/Events/IEventSource';
import type { Script } from '@/domain/Executables/Script/Script'; import type { Script } from '@/domain/Executables/Script/Script';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import type { SelectedScript } from './SelectedScript'; import type { SelectedScript } from './SelectedScript';
import type { ScriptSelectionChangeCommand } from './ScriptSelectionChange'; import type { ScriptSelectionChangeCommand } from './ScriptSelectionChange';
export interface ReadonlyScriptSelection { export interface ReadonlyScriptSelection {
readonly changed: IEventSource<readonly SelectedScript[]>; readonly changed: IEventSource<readonly SelectedScript[]>;
readonly selectedScripts: readonly SelectedScript[]; readonly selectedScripts: readonly SelectedScript[];
isSelected(scriptId: string): boolean; isSelected(scriptExecutableId: ExecutableId): boolean;
} }
export interface ScriptSelection extends ReadonlyScriptSelection { export interface ScriptSelection extends ReadonlyScriptSelection {

View File

@@ -1,3 +1,5 @@
import type { ExecutableId } from '@/domain/Executables/Identifiable';
export type ScriptSelectionStatus = { export type ScriptSelectionStatus = {
readonly isSelected: true; readonly isSelected: true;
readonly isReverted: boolean; readonly isReverted: boolean;
@@ -7,7 +9,7 @@ export type ScriptSelectionStatus = {
}; };
export interface ScriptSelectionChange { export interface ScriptSelectionChange {
readonly scriptId: string; readonly scriptId: ExecutableId;
readonly newStatus: ScriptSelectionStatus; readonly newStatus: ScriptSelectionStatus;
} }

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { ScriptToCategorySelectionMapper } from './Category/ScriptToCategorySelectionMapper'; import { ScriptToCategorySelectionMapper } from './Category/ScriptToCategorySelectionMapper';
import { DebouncedScriptSelection } from './Script/DebouncedScriptSelection'; import { DebouncedScriptSelection } from './Script/DebouncedScriptSelection';
import type { CategorySelection } from './Category/CategorySelection'; import type { CategorySelection } from './Category/CategorySelection';

View File

@@ -1,7 +1,7 @@
import type { CollectionData } from '@/application/collections/'; import type { CollectionData } from '@/application/collections/';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { CategoryCollection } from '@/domain/CategoryCollection'; import { CategoryCollection } from '@/domain/Collection/CategoryCollection';
import type { ProjectDetails } from '@/domain/Project/ProjectDetails'; import type { ProjectDetails } from '@/domain/Project/ProjectDetails';
import { createEnumParser, type EnumParser } from '../Common/Enum'; import { createEnumParser, type EnumParser } from '../Common/Enum';
import { parseCategory, type CategoryParser } from './Executable/CategoryParser'; import { parseCategory, type CategoryParser } from './Executable/CategoryParser';

View File

@@ -3,16 +3,14 @@ import type {
} from '@/application/collections/'; } from '@/application/collections/';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError'; import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import type { Category } from '@/domain/Executables/Category/Category'; import type { Category } from '@/domain/Executables/Category/Category';
import { CollectionCategory } from '@/domain/Executables/Category/CollectionCategory';
import type { Script } from '@/domain/Executables/Script/Script'; import type { Script } from '@/domain/Executables/Script/Script';
import { createCategory, type CategoryFactory } from '@/domain/Executables/Category/CategoryFactory';
import { parseDocs, type DocsParser } from './DocumentationParser'; import { parseDocs, type DocsParser } from './DocumentationParser';
import { parseScript, type ScriptParser } from './Script/ScriptParser'; import { parseScript, type ScriptParser } from './Script/ScriptParser';
import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from './Validation/ExecutableValidator'; import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from './Validation/ExecutableValidator';
import { ExecutableType } from './Validation/ExecutableType'; import { ExecutableType } from './Validation/ExecutableType';
import type { CategoryCollectionSpecificUtilities } from './CategoryCollectionSpecificUtilities'; import type { CategoryCollectionSpecificUtilities } from './CategoryCollectionSpecificUtilities';
let categoryIdCounter = 0;
export const parseCategory: CategoryParser = ( export const parseCategory: CategoryParser = (
category: CategoryData, category: CategoryData,
collectionUtilities: CategoryCollectionSpecificUtilities, collectionUtilities: CategoryCollectionSpecificUtilities,
@@ -59,7 +57,7 @@ function parseCategoryRecursively(
} }
try { try {
return context.categoryUtilities.createCategory({ return context.categoryUtilities.createCategory({
id: categoryIdCounter++, executableId: context.categoryData.category, // Pseudo-ID for uniqueness until real ID support
name: context.categoryData.category, name: context.categoryData.category,
docs: context.categoryUtilities.parseDocs(context.categoryData), docs: context.categoryUtilities.parseDocs(context.categoryData),
subcategories: children.subcategories, subcategories: children.subcategories,
@@ -166,10 +164,6 @@ function hasProperty(
return Object.prototype.hasOwnProperty.call(object, propertyName); return Object.prototype.hasOwnProperty.call(object, propertyName);
} }
export type CategoryFactory = (
...parameters: ConstructorParameters<typeof CollectionCategory>
) => Category;
interface CategoryParserUtilities { interface CategoryParserUtilities {
readonly createCategory: CategoryFactory; readonly createCategory: CategoryFactory;
readonly wrapError: ErrorWithContextWrapper; readonly wrapError: ErrorWithContextWrapper;
@@ -179,7 +173,7 @@ interface CategoryParserUtilities {
} }
const DefaultCategoryParserUtilities: CategoryParserUtilities = { const DefaultCategoryParserUtilities: CategoryParserUtilities = {
createCategory: (...parameters) => new CollectionCategory(...parameters), createCategory,
wrapError: wrapErrorWithAdditionalContext, wrapError: wrapErrorWithAdditionalContext,
createValidator: createExecutableDataValidator, createValidator: createExecutableDataValidator,
parseScript, parseScript,

View File

@@ -1,7 +1,6 @@
import type { ScriptData, CodeScriptData, CallScriptData } from '@/application/collections/'; import type { ScriptData, CodeScriptData, CallScriptData } from '@/application/collections/';
import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines'; import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines';
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax'; import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
import { CollectionScript } from '@/domain/Executables/Script/CollectionScript';
import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel'; import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode'; import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode';
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator'; import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
@@ -11,6 +10,7 @@ import { createScriptCode } from '@/domain/Executables/Script/Code/ScriptCodeFac
import type { Script } from '@/domain/Executables/Script/Script'; import type { Script } from '@/domain/Executables/Script/Script';
import { createEnumParser, type EnumParser } from '@/application/Common/Enum'; import { createEnumParser, type EnumParser } from '@/application/Common/Enum';
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings'; import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
import { createScript, type ScriptFactory } from '@/domain/Executables/Script/ScriptFactory';
import { parseDocs, type DocsParser } from '../DocumentationParser'; import { parseDocs, type DocsParser } from '../DocumentationParser';
import { ExecutableType } from '../Validation/ExecutableType'; import { ExecutableType } from '../Validation/ExecutableType';
import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from '../Validation/ExecutableValidator'; import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from '../Validation/ExecutableValidator';
@@ -38,6 +38,7 @@ export const parseScript: ScriptParser = (
validateScript(data, validator); validateScript(data, validator);
try { try {
const script = scriptUtilities.createScript({ const script = scriptUtilities.createScript({
executableId: data.name, // Pseudo-ID for uniqueness until real ID support
name: data.name, name: data.name,
code: parseCode( code: parseCode(
data, data,
@@ -132,14 +133,6 @@ interface ScriptParserUtilities {
readonly parseDocs: DocsParser; readonly parseDocs: DocsParser;
} }
export type ScriptFactory = (
...parameters: ConstructorParameters<typeof CollectionScript>
) => Script;
const createScript: ScriptFactory = (...parameters) => {
return new CollectionScript(...parameters);
};
const DefaultUtilities: ScriptParserUtilities = { const DefaultUtilities: ScriptParserUtilities = {
levelParser: createEnumParser(RecommendationLevel), levelParser: createEnumParser(RecommendationLevel),
createScript, createScript,

View File

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

View File

@@ -0,0 +1,6 @@
/** Aggregate root */
export type RepositoryEntityId = string;
export interface RepositoryEntity {
readonly id: RepositoryEntityId;
}

View File

@@ -3207,7 +3207,7 @@ actions:
parameters: parameters:
appCapability: bluetoothSync appCapability: bluetoothSync
- -
category: Disable app access to voice activation category: Disable app voice activation
docs: |- # refactor-with-variable: Same • App Access Caution docs: |- # refactor-with-variable: Same • App Access Caution
This category safeguards against unauthorized app activation via voice commands. This category safeguards against unauthorized app activation via voice commands.
@@ -15671,7 +15671,7 @@ actions:
data: '1' data: '1'
deleteOnRevert: 'true' # Missing by default since Windows 10 Pro (≥ 22H2) and Windows 11 Pro (≥ 23H2) deleteOnRevert: 'true' # Missing by default since Windows 10 Pro (≥ 22H2) and Windows 11 Pro (≥ 23H2)
- -
category: Minimize CPU usage during scans category: Disable intensive CPU usage during Defender scans
children: children:
- -
name: Minimize CPU usage during scans name: Minimize CPU usage during scans
@@ -15866,7 +15866,7 @@ actions:
category: Disable scanning archive files category: Disable scanning archive files
children: children:
- -
name: Disable scanning archive files name: Disable Defender archive file scanning
docs: docs:
- https://admx.help/?Category=Windows_10_2016&Policy=Microsoft.Policies.WindowsDefender::Scan_DisableArchiveScanning - https://admx.help/?Category=Windows_10_2016&Policy=Microsoft.Policies.WindowsDefender::Scan_DisableArchiveScanning
# Managing with MpPreference module: # Managing with MpPreference module:

View File

@@ -1,6 +1,6 @@
import { OperatingSystem } from './OperatingSystem'; import { OperatingSystem } from './OperatingSystem';
import type { IApplication } from './IApplication'; import type { IApplication } from './IApplication';
import type { ICategoryCollection } from './ICategoryCollection'; import type { ICategoryCollection } from './Collection/ICategoryCollection';
import type { ProjectDetails } from './Project/ProjectDetails'; import type { ProjectDetails } from './Project/ProjectDetails';
export class Application implements IApplication { export class Application implements IApplication {

View File

@@ -1,11 +1,13 @@
import { getEnumValues, assertInRange } from '@/application/Common/Enum'; import { getEnumValues, assertInRange } from '@/application/Common/Enum';
import { RecommendationLevel } from './Executables/Script/RecommendationLevel'; import { RecommendationLevel } from '../Executables/Script/RecommendationLevel';
import { OperatingSystem } from './OperatingSystem'; import { OperatingSystem } from '../OperatingSystem';
import type { IEntity } from '../infrastructure/Entity/IEntity'; import { validateCategoryCollection } from './Validation/CompositeCategoryCollectionValidator';
import type { Category } from './Executables/Category/Category'; import type { ExecutableId } from '../Executables/Identifiable';
import type { Script } from './Executables/Script/Script'; import type { Category } from '../Executables/Category/Category';
import type { IScriptingDefinition } from './IScriptingDefinition'; import type { Script } from '../Executables/Script/Script';
import type { IScriptingDefinition } from '../IScriptingDefinition';
import type { ICategoryCollection } from './ICategoryCollection'; import type { ICategoryCollection } from './ICategoryCollection';
import type { CategoryCollectionValidator } from './Validation/CategoryCollectionValidator';
export class CategoryCollection implements ICategoryCollection { export class CategoryCollection implements ICategoryCollection {
public readonly os: OperatingSystem; public readonly os: OperatingSystem;
@@ -22,22 +24,24 @@ export class CategoryCollection implements ICategoryCollection {
constructor( constructor(
parameters: CategoryCollectionInitParameters, parameters: CategoryCollectionInitParameters,
validate: CategoryCollectionValidator = validateCategoryCollection,
) { ) {
this.os = parameters.os; this.os = parameters.os;
this.actions = parameters.actions; this.actions = parameters.actions;
this.scripting = parameters.scripting; this.scripting = parameters.scripting;
this.queryable = makeQueryable(this.actions); this.queryable = makeQueryable(this.actions);
assertInRange(this.os, OperatingSystem); validate({
ensureValid(this.queryable); allScripts: this.queryable.allScripts,
ensureNoDuplicates(this.queryable.allCategories); allCategories: this.queryable.allCategories,
ensureNoDuplicates(this.queryable.allScripts); operatingSystem: this.os,
});
} }
public getCategory(categoryId: number): Category { public getCategory(executableId: ExecutableId): Category {
const category = this.queryable.allCategories.find((c) => c.id === categoryId); const category = this.queryable.allCategories.find((c) => c.executableId === executableId);
if (!category) { if (!category) {
throw new Error(`Missing category with ID: "${categoryId}"`); throw new Error(`Missing category with ID: "${executableId}"`);
} }
return category; return category;
} }
@@ -48,10 +52,10 @@ export class CategoryCollection implements ICategoryCollection {
return scripts ?? []; return scripts ?? [];
} }
public getScript(scriptId: string): Script { public getScript(executableId: ExecutableId): Script {
const script = this.queryable.allScripts.find((s) => s.id === scriptId); const script = this.queryable.allScripts.find((s) => s.executableId === executableId);
if (!script) { if (!script) {
throw new Error(`missing script: ${scriptId}`); throw new Error(`Missing script: ${executableId}`);
} }
return script; return script;
} }
@@ -65,21 +69,6 @@ export class CategoryCollection implements ICategoryCollection {
} }
} }
function ensureNoDuplicates<TKey>(entities: ReadonlyArray<IEntity<TKey>>) {
const isUniqueInArray = (id: TKey, index: number, array: readonly TKey[]) => array
.findIndex((otherId) => otherId === id) !== index;
const duplicatedIds = entities
.map((entity) => entity.id)
.filter((id, index, array) => !isUniqueInArray(id, index, array))
.filter(isUniqueInArray);
if (duplicatedIds.length > 0) {
const duplicatedIdsText = duplicatedIds.map((id) => `"${id}"`).join(',');
throw new Error(
`Duplicate entities are detected with following id(s): ${duplicatedIdsText}`,
);
}
}
export interface CategoryCollectionInitParameters { export interface CategoryCollectionInitParameters {
readonly os: OperatingSystem; readonly os: OperatingSystem;
readonly actions: ReadonlyArray<Category>; readonly actions: ReadonlyArray<Category>;
@@ -92,35 +81,12 @@ interface QueryableCollection {
readonly scriptsByLevel: Map<RecommendationLevel, readonly Script[]>; readonly scriptsByLevel: Map<RecommendationLevel, readonly Script[]>;
} }
function ensureValid(application: QueryableCollection) { function flattenCategoryHierarchy(
ensureValidCategories(application.allCategories);
ensureValidScripts(application.allScripts);
}
function ensureValidCategories(allCategories: readonly Category[]) {
if (!allCategories.length) {
throw new Error('must consist of at least one category');
}
}
function ensureValidScripts(allScripts: readonly Script[]) {
if (!allScripts.length) {
throw new Error('must consist of at least one script');
}
const missingRecommendationLevels = getEnumValues(RecommendationLevel)
.filter((level) => allScripts.every((script) => script.level !== level));
if (missingRecommendationLevels.length > 0) {
throw new Error('none of the scripts are recommended as'
+ ` "${missingRecommendationLevels.map((level) => RecommendationLevel[level]).join(', "')}".`);
}
}
function flattenApplication(
categories: ReadonlyArray<Category>, categories: ReadonlyArray<Category>,
): [Category[], Script[]] { ): [Category[], Script[]] {
const [subCategories, subScripts] = (categories || []) const [subCategories, subScripts] = (categories || [])
// Parse children // Parse children
.map((category) => flattenApplication(category.subCategories)) .map((category) => flattenCategoryHierarchy(category.subcategories))
// Flatten results // Flatten results
.reduce(([previousCategories, previousScripts], [currentCategories, currentScripts]) => { .reduce(([previousCategories, previousScripts], [currentCategories, currentScripts]) => {
return [ return [
@@ -143,7 +109,7 @@ function flattenApplication(
function makeQueryable( function makeQueryable(
actions: ReadonlyArray<Category>, actions: ReadonlyArray<Category>,
): QueryableCollection { ): QueryableCollection {
const flattened = flattenApplication(actions); const flattened = flattenCategoryHierarchy(actions);
return { return {
allCategories: flattened[0], allCategories: flattened[0],
allScripts: flattened[1], allScripts: flattened[1],

View File

@@ -3,6 +3,7 @@ import { OperatingSystem } from '@/domain/OperatingSystem';
import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel'; import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
import type { Script } from '@/domain/Executables/Script/Script'; import type { Script } from '@/domain/Executables/Script/Script';
import type { Category } from '@/domain/Executables/Category/Category'; import type { Category } from '@/domain/Executables/Category/Category';
import type { ExecutableId } from '../Executables/Identifiable';
export interface ICategoryCollection { export interface ICategoryCollection {
readonly scripting: IScriptingDefinition; readonly scripting: IScriptingDefinition;
@@ -12,8 +13,8 @@ export interface ICategoryCollection {
readonly actions: ReadonlyArray<Category>; readonly actions: ReadonlyArray<Category>;
getScriptsByLevel(level: RecommendationLevel): ReadonlyArray<Script>; getScriptsByLevel(level: RecommendationLevel): ReadonlyArray<Script>;
getCategory(categoryId: number): Category; getCategory(categoryId: ExecutableId): Category;
getScript(scriptId: string): Script; getScript(scriptId: ExecutableId): Script;
getAllScripts(): ReadonlyArray<Script>; getAllScripts(): ReadonlyArray<Script>;
getAllCategories(): ReadonlyArray<Category>; getAllCategories(): ReadonlyArray<Category>;
} }

View File

@@ -0,0 +1,15 @@
import type { Category } from '@/domain/Executables/Category/Category';
import type { Script } from '@/domain/Executables/Script/Script';
import type { OperatingSystem } from '@/domain/OperatingSystem';
export interface CategoryCollectionValidationContext {
readonly allScripts: readonly Script[];
readonly allCategories: readonly Category[];
readonly operatingSystem: OperatingSystem;
}
export interface CategoryCollectionValidator {
(
context: CategoryCollectionValidationContext,
): void;
}

View File

@@ -0,0 +1,33 @@
import { ensurePresenceOfAtLeastOneScript } from './Rules/EnsurePresenceOfAtLeastOneScript';
import { ensurePresenceOfAtLeastOneCategory } from './Rules/EnsurePresenceOfAtLeastOneCategory';
import { ensureUniqueIdsAcrossExecutables } from './Rules/EnsureUniqueIdsAcrossExecutables';
import { ensureKnownOperatingSystem } from './Rules/EnsureKnownOperatingSystem';
import type { CategoryCollectionValidationContext, CategoryCollectionValidator } from './CategoryCollectionValidator';
export type CompositeCategoryCollectionValidator = CategoryCollectionValidator & {
(
...args: [
...Parameters<CategoryCollectionValidator>,
(readonly CategoryCollectionValidator[])?,
]
): void;
};
export const validateCategoryCollection: CompositeCategoryCollectionValidator = (
context: CategoryCollectionValidationContext,
validators: readonly CategoryCollectionValidator[] = DefaultValidators,
) => {
if (!validators.length) {
throw new Error('No validators provided.');
}
for (const validate of validators) {
validate(context);
}
};
const DefaultValidators: readonly CategoryCollectionValidator[] = [
ensureKnownOperatingSystem,
ensurePresenceOfAtLeastOneScript,
ensurePresenceOfAtLeastOneCategory,
ensureUniqueIdsAcrossExecutables,
];

View File

@@ -0,0 +1,9 @@
import { assertInRange } from '@/application/Common/Enum';
import { OperatingSystem } from '@/domain/OperatingSystem';
import type { CategoryCollectionValidator } from '../CategoryCollectionValidator';
export const ensureKnownOperatingSystem: CategoryCollectionValidator = (
context,
) => {
assertInRange(context.operatingSystem, OperatingSystem);
};

View File

@@ -0,0 +1,35 @@
import { getEnumValues } from '@/application/Common/Enum';
import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
import type { Script } from '@/domain/Executables/Script/Script';
import type { CategoryCollectionValidator } from '../CategoryCollectionValidator';
export const ensurePresenceOfAllRecommendationLevels: CategoryCollectionValidator = (
context,
) => {
const unrepresentedRecommendationLevels = getUnrepresentedRecommendationLevels(
context.allScripts,
);
if (unrepresentedRecommendationLevels.length === 0) {
return;
}
const formattedRecommendationLevels = unrepresentedRecommendationLevels
.map((level) => getDisplayName(level))
.join(', ');
throw new Error(`Missing recommendation levels: ${formattedRecommendationLevels}.`);
};
function getUnrepresentedRecommendationLevels(
scripts: readonly Script[],
): (RecommendationLevel | undefined)[] {
const expectedLevels = [
undefined,
...getEnumValues(RecommendationLevel),
];
return expectedLevels.filter(
(level) => scripts.every((script) => script.level !== level),
);
}
function getDisplayName(level: RecommendationLevel | undefined): string {
return level === undefined ? 'None' : RecommendationLevel[level];
}

View File

@@ -0,0 +1,9 @@
import type { CategoryCollectionValidator } from '../CategoryCollectionValidator';
export const ensurePresenceOfAtLeastOneCategory: CategoryCollectionValidator = (
context,
) => {
if (!context.allCategories.length) {
throw new Error('Collection must have at least one category');
}
};

View File

@@ -0,0 +1,9 @@
import type { CategoryCollectionValidator } from '../CategoryCollectionValidator';
export const ensurePresenceOfAtLeastOneScript: CategoryCollectionValidator = (
context,
) => {
if (!context.allScripts.length) {
throw new Error('Collection must have at least one script');
}
};

View File

@@ -0,0 +1,43 @@
import type { Identifiable } from '@/domain/Executables/Identifiable';
import type { CategoryCollectionValidator } from '../CategoryCollectionValidator';
export const ensureUniqueIdsAcrossExecutables: CategoryCollectionValidator = (
context,
) => {
const allExecutables: readonly Identifiable[] = [
...context.allCategories,
...context.allScripts,
];
ensureNoDuplicateIds(allExecutables);
};
function ensureNoDuplicateIds(
executables: readonly Identifiable[],
) {
const duplicateExecutables = getExecutablesWithDuplicateIds(executables);
if (duplicateExecutables.length === 0) {
return;
}
const formattedDuplicateIds = duplicateExecutables.map(
(executable) => `"${executable.executableId}"`,
).join(', ');
throw new Error(`Duplicate executable IDs found: ${formattedDuplicateIds}`);
}
function getExecutablesWithDuplicateIds(
executables: readonly Identifiable[],
): Identifiable[] {
return executables
.filter(
(executable, index, array) => {
const otherIndex = array.findIndex(
(otherExecutable) => haveIdenticalIds(executable, otherExecutable),
);
return otherIndex !== index;
},
);
}
function haveIdenticalIds(first: Identifiable, second: Identifiable): boolean {
return first.executableId === second.executableId;
}

View File

@@ -1,10 +1,9 @@
import type { Script } from '../Script/Script'; import type { Script } from '../Script/Script';
import type { Executable } from '../Executable'; import type { Executable } from '../Executable';
export interface Category extends Executable<number> { export interface Category extends Executable {
readonly id: number;
readonly name: string; readonly name: string;
readonly subCategories: ReadonlyArray<Category>; readonly subcategories: ReadonlyArray<Category>;
readonly scripts: ReadonlyArray<Script>; readonly scripts: ReadonlyArray<Script>;
includes(script: Script): boolean; includes(script: Script): boolean;
getAllScriptsRecursively(): ReadonlyArray<Script>; getAllScriptsRecursively(): ReadonlyArray<Script>;

View File

@@ -1,29 +1,51 @@
import { BaseEntity } from '@/infrastructure/Entity/BaseEntity'; import type { Category } from '@/domain/Executables/Category/Category';
import type { Category } from './Category'; import type { Script } from '@/domain/Executables/Script/Script';
import type { Script } from '../Script/Script'; import type { ExecutableId } from '../Identifiable';
export class CollectionCategory extends BaseEntity<number> implements Category { export type CategoryFactory = (
private allSubScripts?: ReadonlyArray<Script> = undefined; parameters: CategoryInitParameters,
) => Category;
export interface CategoryInitParameters {
readonly executableId: ExecutableId;
readonly name: string;
readonly docs: ReadonlyArray<string>;
readonly subcategories: ReadonlyArray<Category>;
readonly scripts: ReadonlyArray<Script>;
}
export const createCategory: CategoryFactory = (
parameters,
) => {
return new CollectionCategory(parameters);
};
class CollectionCategory implements Category {
public readonly executableId: ExecutableId;
public readonly name: string; public readonly name: string;
public readonly docs: ReadonlyArray<string>; public readonly docs: ReadonlyArray<string>;
public readonly subCategories: ReadonlyArray<Category>; public readonly subcategories: ReadonlyArray<Category>;
public readonly scripts: ReadonlyArray<Script>; public readonly scripts: ReadonlyArray<Script>;
private allSubScripts?: ReadonlyArray<Script> = undefined;
constructor(parameters: CategoryInitParameters) { constructor(parameters: CategoryInitParameters) {
super(parameters.id);
validateParameters(parameters); validateParameters(parameters);
this.executableId = parameters.executableId;
this.name = parameters.name; this.name = parameters.name;
this.docs = parameters.docs; this.docs = parameters.docs;
this.subCategories = parameters.subcategories; this.subcategories = parameters.subcategories;
this.scripts = parameters.scripts; this.scripts = parameters.scripts;
} }
public includes(script: Script): boolean { public includes(script: Script): boolean {
return this.getAllScriptsRecursively().some((childScript) => childScript.id === script.id); return this
.getAllScriptsRecursively()
.some((childScript) => childScript.executableId === script.executableId);
} }
public getAllScriptsRecursively(): readonly Script[] { public getAllScriptsRecursively(): readonly Script[] {
@@ -34,22 +56,17 @@ export class CollectionCategory extends BaseEntity<number> implements Category {
} }
} }
export interface CategoryInitParameters {
readonly id: number;
readonly name: string;
readonly docs: ReadonlyArray<string>;
readonly subcategories: ReadonlyArray<Category>;
readonly scripts: ReadonlyArray<Script>;
}
function parseScriptsRecursively(category: Category): ReadonlyArray<Script> { function parseScriptsRecursively(category: Category): ReadonlyArray<Script> {
return [ return [
...category.scripts, ...category.scripts,
...category.subCategories.flatMap((c) => c.getAllScriptsRecursively()), ...category.subcategories.flatMap((c) => c.getAllScriptsRecursively()),
]; ];
} }
function validateParameters(parameters: CategoryInitParameters) { function validateParameters(parameters: CategoryInitParameters) {
if (!parameters.executableId) {
throw new Error('missing ID');
}
if (!parameters.name) { if (!parameters.name) {
throw new Error('missing name'); throw new Error('missing name');
} }

View File

@@ -1,6 +1,6 @@
import type { IEntity } from '@/infrastructure/Entity/IEntity';
import type { Documentable } from './Documentable'; import type { Documentable } from './Documentable';
import type { Identifiable } from './Identifiable';
export interface Executable<TExecutableKey> export interface Executable
extends Documentable, IEntity<TExecutableKey> { extends Documentable, Identifiable {
} }

View File

@@ -0,0 +1,5 @@
export type ExecutableId = string;
export interface Identifiable {
readonly executableId: ExecutableId;
}

View File

@@ -3,7 +3,7 @@ import type { Executable } from '../Executable';
import type { Documentable } from '../Documentable'; import type { Documentable } from '../Documentable';
import type { ScriptCode } from './Code/ScriptCode'; import type { ScriptCode } from './Code/ScriptCode';
export interface Script extends Executable<string>, Documentable { export interface Script extends Executable, Documentable {
readonly name: string; readonly name: string;
readonly level?: RecommendationLevel; readonly level?: RecommendationLevel;
readonly code: ScriptCode; readonly code: ScriptCode;

View File

@@ -1,9 +1,27 @@
import { BaseEntity } from '@/infrastructure/Entity/BaseEntity';
import { RecommendationLevel } from './RecommendationLevel'; import { RecommendationLevel } from './RecommendationLevel';
import type { Script } from './Script';
import type { ScriptCode } from './Code/ScriptCode'; import type { ScriptCode } from './Code/ScriptCode';
import type { Script } from './Script';
import type { ExecutableId } from '../Identifiable';
export interface ScriptInitParameters {
readonly executableId: ExecutableId;
readonly name: string;
readonly code: ScriptCode;
readonly docs: ReadonlyArray<string>;
readonly level?: RecommendationLevel;
}
export type ScriptFactory = (
parameters: ScriptInitParameters,
) => Script;
export const createScript: ScriptFactory = (parameters) => {
return new CollectionScript(parameters);
};
class CollectionScript implements Script {
public readonly executableId: ExecutableId;
export class CollectionScript extends BaseEntity<string> implements Script {
public readonly name: string; public readonly name: string;
public readonly code: ScriptCode; public readonly code: ScriptCode;
@@ -13,7 +31,7 @@ export class CollectionScript extends BaseEntity<string> implements Script {
public readonly level?: RecommendationLevel; public readonly level?: RecommendationLevel;
constructor(parameters: ScriptInitParameters) { constructor(parameters: ScriptInitParameters) {
super(parameters.name); this.executableId = parameters.executableId;
this.name = parameters.name; this.name = parameters.name;
this.code = parameters.code; this.code = parameters.code;
this.docs = parameters.docs; this.docs = parameters.docs;
@@ -26,13 +44,6 @@ export class CollectionScript extends BaseEntity<string> implements Script {
} }
} }
export interface ScriptInitParameters {
readonly name: string;
readonly code: ScriptCode;
readonly docs: ReadonlyArray<string>;
readonly level?: RecommendationLevel;
}
function validateLevel(level?: RecommendationLevel) { function validateLevel(level?: RecommendationLevel) {
if (level !== undefined && !(level in RecommendationLevel)) { if (level !== undefined && !(level in RecommendationLevel)) {
throw new Error(`invalid level: ${level}`); throw new Error(`invalid level: ${level}`);

View File

@@ -1,4 +1,4 @@
import type { ICategoryCollection } from './ICategoryCollection'; import type { ICategoryCollection } from './Collection/ICategoryCollection';
import type { ProjectDetails } from './Project/ProjectDetails'; import type { ProjectDetails } from './Project/ProjectDetails';
import type { OperatingSystem } from './OperatingSystem'; import type { OperatingSystem } from './OperatingSystem';

View File

@@ -1,14 +0,0 @@
import { isNumber } from '@/TypeHelpers';
import type { IEntity } from './IEntity';
export abstract class BaseEntity<TId> implements IEntity<TId> {
protected constructor(public id: TId) {
if (!isNumber(id) && !id) {
throw new Error('Id cannot be null or empty');
}
}
public equals(otherId: TId): boolean {
return this.id === otherId;
}
}

View File

@@ -1,5 +0,0 @@
/** Aggregate root */
export interface IEntity<TId> {
id: TId;
equals(other: TId): boolean;
}

View File

@@ -1,12 +1,15 @@
import type { Repository } from '../../application/Repository/Repository'; import type { Repository } from '../../application/Repository/Repository';
import type { IEntity } from '../Entity/IEntity'; import type { RepositoryEntity, RepositoryEntityId } from '../../application/Repository/RepositoryEntity';
export class InMemoryRepository<TKey, TEntity extends IEntity<TKey>> export class InMemoryRepository<TEntity extends RepositoryEntity>
implements Repository<TKey, TEntity> { implements Repository<TEntity> {
private readonly items: TEntity[]; private readonly items: TEntity[];
constructor(items?: TEntity[]) { constructor(items?: readonly TEntity[]) {
this.items = items ?? new Array<TEntity>(); this.items = new Array<TEntity>();
if (items) {
this.items.push(...items);
}
} }
public get length(): number { public get length(): number {
@@ -17,7 +20,7 @@ implements Repository<TKey, TEntity> {
return predicate ? this.items.filter(predicate) : this.items; return predicate ? this.items.filter(predicate) : this.items;
} }
public getById(id: TKey): TEntity { public getById(id: RepositoryEntityId): TEntity {
const items = this.getItems((entity) => entity.id === id); const items = this.getItems((entity) => entity.id === id);
if (!items.length) { if (!items.length) {
throw new Error(`missing item: ${id}`); throw new Error(`missing item: ${id}`);
@@ -39,7 +42,7 @@ implements Repository<TKey, TEntity> {
this.items.push(item); this.items.push(item);
} }
public removeItem(id: TKey): void { public removeItem(id: RepositoryEntityId): void {
const index = this.items.findIndex((item) => item.id === id); const index = this.items.findIndex((item) => item.id === id);
if (index === -1) { if (index === -1) {
throw new Error(`Cannot remove (id: ${id}) as it does not exist`); throw new Error(`Cannot remove (id: ${id}) as it does not exist`);
@@ -47,7 +50,7 @@ implements Repository<TKey, TEntity> {
this.items.splice(index, 1); this.items.splice(index, 1);
} }
public exists(id: TKey): boolean { public exists(id: RepositoryEntityId): boolean {
const index = this.items.findIndex((item) => item.id === id); const index = this.items.findIndex((item) => item.id === id);
return index !== -1; return index !== -1;
} }

View File

@@ -1,7 +1,7 @@
import type { Script } from '@/domain/Executables/Script/Script'; import type { Script } from '@/domain/Executables/Script/Script';
import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel'; import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
import { scrambledEqual } from '@/application/Common/Array'; import { scrambledEqual } from '@/application/Common/Array';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import type { ReadonlyScriptSelection, ScriptSelection } from '@/application/Context/State/Selection/Script/ScriptSelection'; import type { ReadonlyScriptSelection, ScriptSelection } from '@/application/Context/State/Selection/Script/ScriptSelection';
import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript'; import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { RecommendationStatusType } from './RecommendationStatusType'; import { RecommendationStatusType } from './RecommendationStatusType';
@@ -99,6 +99,6 @@ function areAllSelected(
if (expectedScripts.length < selectedScriptIds.length) { if (expectedScripts.length < selectedScriptIds.length) {
return false; return false;
} }
const expectedScriptIds = expectedScripts.map((script) => script.id); const expectedScriptIds = expectedScripts.map((script) => script.executableId);
return scrambledEqual(selectedScriptIds, expectedScriptIds); return scrambledEqual(selectedScriptIds, expectedScriptIds);
} }

View File

@@ -90,7 +90,7 @@ import {
} from 'vue'; } from 'vue';
import { injectKey } from '@/presentation/injectionSymbols'; import { injectKey } from '@/presentation/injectionSymbols';
import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue'; import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import MenuOptionList from '../MenuOptionList.vue'; import MenuOptionList from '../MenuOptionList.vue';
import MenuOptionListItem from '../MenuOptionListItem.vue'; import MenuOptionListItem from '../MenuOptionListItem.vue';
import { setCurrentRecommendationStatus, getCurrentRecommendationStatus } from './RecommendationStatusHandler'; import { setCurrentRecommendationStatus, getCurrentRecommendationStatus } from './RecommendationStatusHandler';

View File

@@ -44,6 +44,7 @@ import {
} from 'vue'; } from 'vue';
import { injectKey } from '@/presentation/injectionSymbols'; import { injectKey } from '@/presentation/injectionSymbols';
import SizeObserver from '@/presentation/components/Shared/SizeObserver.vue'; import SizeObserver from '@/presentation/components/Shared/SizeObserver.vue';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import { hasDirective } from './NonCollapsingDirective'; import { hasDirective } from './NonCollapsingDirective';
import CardListItem from './CardListItem.vue'; import CardListItem from './CardListItem.vue';
@@ -58,12 +59,12 @@ export default defineComponent({
const width = ref<number | undefined>(); const width = ref<number | undefined>();
const categoryIds = computed<readonly number[]>( const categoryIds = computed<readonly ExecutableId[]>(
() => currentState.value.collection.actions.map((category) => category.id), () => currentState.value.collection.actions.map((category) => category.executableId),
); );
const activeCategoryId = ref<number | undefined>(undefined); const activeCategoryId = ref<ExecutableId | undefined>(undefined);
function onSelected(categoryId: number, isExpanded: boolean) { function onSelected(categoryId: ExecutableId, isExpanded: boolean) {
activeCategoryId.value = isExpanded ? categoryId : undefined; activeCategoryId.value = isExpanded ? categoryId : undefined;
} }

View File

@@ -56,12 +56,14 @@
<script lang="ts"> <script lang="ts">
import { import {
defineComponent, computed, shallowRef, defineComponent, computed, shallowRef,
type PropType,
} from 'vue'; } from 'vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue'; import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import FlatButton from '@/presentation/components/Shared/FlatButton.vue'; import FlatButton from '@/presentation/components/Shared/FlatButton.vue';
import { injectKey } from '@/presentation/injectionSymbols'; import { injectKey } from '@/presentation/injectionSymbols';
import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue'; import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue';
import { sleep } from '@/infrastructure/Threading/AsyncSleep'; import { sleep } from '@/infrastructure/Threading/AsyncSleep';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import CardSelectionIndicator from './CardSelectionIndicator.vue'; import CardSelectionIndicator from './CardSelectionIndicator.vue';
import CardExpandTransition from './CardExpandTransition.vue'; import CardExpandTransition from './CardExpandTransition.vue';
import CardExpansionArrow from './CardExpansionArrow.vue'; import CardExpansionArrow from './CardExpansionArrow.vue';
@@ -77,11 +79,11 @@ export default defineComponent({
}, },
props: { props: {
categoryId: { categoryId: {
type: Number, type: String as PropType<ExecutableId>,
required: true, required: true,
}, },
activeCategoryId: { activeCategoryId: {
type: Number, type: String as PropType<ExecutableId>,
default: undefined, default: undefined,
}, },
}, },

View File

@@ -12,11 +12,12 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, computed } from 'vue'; import { defineComponent, computed, type PropType } from 'vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue'; import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import { injectKey } from '@/presentation/injectionSymbols'; import { injectKey } from '@/presentation/injectionSymbols';
import type { Category } from '@/domain/Executables/Category/Category'; import type { Category } from '@/domain/Executables/Category/Category';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
export default defineComponent({ export default defineComponent({
components: { components: {
@@ -24,7 +25,7 @@ export default defineComponent({
}, },
props: { props: {
categoryId: { categoryId: {
type: Number, type: String as PropType<ExecutableId>,
required: true, required: true,
}, },
}, },

View File

@@ -1,10 +1,12 @@
import type { ExecutableId } from '@/domain/Executables/Identifiable';
export enum NodeType { export enum NodeType {
Script, Script,
Category, Category,
} }
export interface NodeMetadata { export interface NodeMetadata {
readonly id: string; readonly executableId: ExecutableId;
readonly text: string; readonly text: string;
readonly isReversible: boolean; readonly isReversible: boolean;
readonly docs: ReadonlyArray<string>; readonly docs: ReadonlyArray<string>;

View File

@@ -12,7 +12,7 @@ import {
} from 'vue'; } from 'vue';
import { injectKey } from '@/presentation/injectionSymbols'; import { injectKey } from '@/presentation/injectionSymbols';
import type { NodeMetadata } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata'; import type { NodeMetadata } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { getReverter } from './Reverter/ReverterFactory'; import { getReverter } from './Reverter/ReverterFactory';
import ToggleSwitch from './ToggleSwitch.vue'; import ToggleSwitch from './ToggleSwitch.vue';
import type { Reverter } from './Reverter/Reverter'; import type { Reverter } from './Reverter/Reverter';

View File

@@ -1,17 +1,19 @@
import type { UserSelection } from '@/application/Context/State/Selection/UserSelection'; import type { UserSelection } from '@/application/Context/State/Selection/UserSelection';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript'; import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { getCategoryId } from '../../TreeViewAdapter/CategoryNodeMetadataConverter'; import type { ExecutableId } from '@/domain/Executables/Identifiable';
import { createExecutableIdFromNodeId } from '../../TreeViewAdapter/CategoryNodeMetadataConverter';
import { ScriptReverter } from './ScriptReverter'; import { ScriptReverter } from './ScriptReverter';
import type { Reverter } from './Reverter'; import type { Reverter } from './Reverter';
import type { TreeNodeId } from '../../TreeView/Node/TreeNode';
export class CategoryReverter implements Reverter { export class CategoryReverter implements Reverter {
private readonly categoryId: number; private readonly categoryId: ExecutableId;
private readonly scriptReverters: ReadonlyArray<ScriptReverter>; private readonly scriptReverters: ReadonlyArray<ScriptReverter>;
constructor(nodeId: string, collection: ICategoryCollection) { constructor(nodeId: TreeNodeId, collection: ICategoryCollection) {
this.categoryId = getCategoryId(nodeId); this.categoryId = createExecutableIdFromNodeId(nodeId);
this.scriptReverters = createScriptReverters(this.categoryId, collection); this.scriptReverters = createScriptReverters(this.categoryId, collection);
} }
@@ -37,12 +39,12 @@ export class CategoryReverter implements Reverter {
} }
function createScriptReverters( function createScriptReverters(
categoryId: number, categoryId: ExecutableId,
collection: ICategoryCollection, collection: ICategoryCollection,
): ScriptReverter[] { ): ScriptReverter[] {
const category = collection.getCategory(categoryId); const category = collection.getCategory(categoryId);
const scripts = category const scripts = category
.getAllScriptsRecursively() .getAllScriptsRecursively()
.filter((script) => script.canRevert()); .filter((script) => script.canRevert());
return scripts.map((script) => new ScriptReverter(script.id)); return scripts.map((script) => new ScriptReverter(script.executableId));
} }

View File

@@ -1,15 +1,18 @@
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { type NodeMetadata, NodeType } from '../NodeMetadata'; import { type NodeMetadata, NodeType } from '../NodeMetadata';
import { ScriptReverter } from './ScriptReverter'; import { ScriptReverter } from './ScriptReverter';
import { CategoryReverter } from './CategoryReverter'; import { CategoryReverter } from './CategoryReverter';
import type { Reverter } from './Reverter'; import type { Reverter } from './Reverter';
export function getReverter(node: NodeMetadata, collection: ICategoryCollection): Reverter { export function getReverter(
node: NodeMetadata,
collection: ICategoryCollection,
): Reverter {
switch (node.type) { switch (node.type) {
case NodeType.Category: case NodeType.Category:
return new CategoryReverter(node.id, collection); return new CategoryReverter(node.executableId, collection);
case NodeType.Script: case NodeType.Script:
return new ScriptReverter(node.id); return new ScriptReverter(node.executableId);
default: default:
throw new Error('Unknown script type'); throw new Error('Unknown script type');
} }

View File

@@ -1,13 +1,15 @@
import type { UserSelection } from '@/application/Context/State/Selection/UserSelection'; import type { UserSelection } from '@/application/Context/State/Selection/UserSelection';
import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript'; import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { getScriptId } from '../../TreeViewAdapter/CategoryNodeMetadataConverter'; import type { ExecutableId } from '@/domain/Executables/Identifiable';
import { createExecutableIdFromNodeId } from '../../TreeViewAdapter/CategoryNodeMetadataConverter';
import type { Reverter } from './Reverter'; import type { Reverter } from './Reverter';
import type { TreeNodeId } from '../../TreeView/Node/TreeNode';
export class ScriptReverter implements Reverter { export class ScriptReverter implements Reverter {
private readonly scriptId: string; private readonly scriptId: ExecutableId;
constructor(nodeId: string) { constructor(nodeId: TreeNodeId) {
this.scriptId = getScriptId(nodeId); this.scriptId = createExecutableIdFromNodeId(nodeId);
} }
public getState(selectedScripts: ReadonlyArray<SelectedScript>): boolean { public getState(selectedScripts: ReadonlyArray<SelectedScript>): boolean {

View File

@@ -24,8 +24,9 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, toRef } from 'vue'; import { defineComponent, toRef, type PropType } from 'vue';
import { injectKey } from '@/presentation/injectionSymbols'; import { injectKey } from '@/presentation/injectionSymbols';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import TreeView from './TreeView/TreeView.vue'; import TreeView from './TreeView/TreeView.vue';
import NodeContent from './NodeContent/NodeContent.vue'; import NodeContent from './NodeContent/NodeContent.vue';
import { useTreeViewFilterEvent } from './TreeViewAdapter/UseTreeViewFilterEvent'; import { useTreeViewFilterEvent } from './TreeViewAdapter/UseTreeViewFilterEvent';
@@ -41,7 +42,7 @@ export default defineComponent({
}, },
props: { props: {
categoryId: { categoryId: {
type: [Number], type: String as PropType<ExecutableId>,
default: undefined, default: undefined,
}, },
hasTopPadding: { hasTopPadding: {

View File

@@ -1,5 +1,7 @@
export type TreeInputNodeDataId = string;
export interface TreeInputNodeData { export interface TreeInputNodeData {
readonly id: string; readonly id: TreeInputNodeDataId;
readonly children?: readonly TreeInputNodeData[]; readonly children?: readonly TreeInputNodeData[];
readonly parent?: TreeInputNodeData | null; readonly parent?: TreeInputNodeData | null;
readonly data?: object; readonly data?: object;

View File

@@ -56,7 +56,7 @@ import { useNodeState } from './UseNodeState';
import LeafTreeNode from './LeafTreeNode.vue'; import LeafTreeNode from './LeafTreeNode.vue';
import InteractableNode from './InteractableNode.vue'; import InteractableNode from './InteractableNode.vue';
import type { TreeRoot } from '../TreeRoot/TreeRoot'; import type { TreeRoot } from '../TreeRoot/TreeRoot';
import type { TreeNode } from './TreeNode'; import type { TreeNode, TreeNodeId } from './TreeNode';
import type { NodeRenderingStrategy } from '../Rendering/Scheduling/NodeRenderingStrategy'; import type { NodeRenderingStrategy } from '../Rendering/Scheduling/NodeRenderingStrategy';
import type { PropType } from 'vue'; import type { PropType } from 'vue';
@@ -69,7 +69,7 @@ export default defineComponent({
}, },
props: { props: {
nodeId: { nodeId: {
type: String, type: String as PropType<TreeNodeId>,
required: true, required: true,
}, },
treeRoot: { treeRoot: {

View File

@@ -18,13 +18,13 @@ import { useCurrentTreeNodes } from '../UseCurrentTreeNodes';
import { useNodeState } from './UseNodeState'; import { useNodeState } from './UseNodeState';
import { useKeyboardInteractionState } from './UseKeyboardInteractionState'; import { useKeyboardInteractionState } from './UseKeyboardInteractionState';
import type { TreeRoot } from '../TreeRoot/TreeRoot'; import type { TreeRoot } from '../TreeRoot/TreeRoot';
import type { TreeNode } from './TreeNode'; import type { TreeNode, TreeNodeId } from './TreeNode';
import type { PropType } from 'vue'; import type { PropType } from 'vue';
export default defineComponent({ export default defineComponent({
props: { props: {
nodeId: { nodeId: {
type: String, type: String as PropType<TreeNodeId>,
required: true, required: true,
}, },
treeRoot: { treeRoot: {

View File

@@ -28,7 +28,7 @@ import { defineComponent, computed, toRef } from 'vue';
import { useCurrentTreeNodes } from '../UseCurrentTreeNodes'; import { useCurrentTreeNodes } from '../UseCurrentTreeNodes';
import NodeCheckbox from './NodeCheckbox.vue'; import NodeCheckbox from './NodeCheckbox.vue';
import InteractableNode from './InteractableNode.vue'; import InteractableNode from './InteractableNode.vue';
import type { TreeNode } from './TreeNode'; import type { TreeNode, TreeNodeId } from './TreeNode';
import type { TreeRoot } from '../TreeRoot/TreeRoot'; import type { TreeRoot } from '../TreeRoot/TreeRoot';
import type { PropType } from 'vue'; import type { PropType } from 'vue';
@@ -39,7 +39,7 @@ export default defineComponent({
}, },
props: { props: {
nodeId: { nodeId: {
type: String, type: String as PropType<TreeNodeId>,
required: true, required: true,
}, },
treeRoot: { treeRoot: {

View File

@@ -14,13 +14,13 @@ import { useCurrentTreeNodes } from '../UseCurrentTreeNodes';
import { useNodeState } from './UseNodeState'; import { useNodeState } from './UseNodeState';
import { TreeNodeCheckState } from './State/CheckState'; import { TreeNodeCheckState } from './State/CheckState';
import type { TreeRoot } from '../TreeRoot/TreeRoot'; import type { TreeRoot } from '../TreeRoot/TreeRoot';
import type { TreeNode } from './TreeNode'; import type { TreeNode, TreeNodeId } from './TreeNode';
import type { PropType } from 'vue'; import type { PropType } from 'vue';
export default defineComponent({ export default defineComponent({
props: { props: {
nodeId: { nodeId: {
type: String, type: String as PropType<TreeNodeId>,
required: true, required: true,
}, },
treeRoot: { treeRoot: {

View File

@@ -1,8 +1,10 @@
import type { HierarchyAccess, HierarchyReader } from './Hierarchy/HierarchyAccess'; import type { HierarchyAccess, HierarchyReader } from './Hierarchy/HierarchyAccess';
import type { TreeNodeStateAccess, TreeNodeStateReader } from './State/StateAccess'; import type { TreeNodeStateAccess, TreeNodeStateReader } from './State/StateAccess';
export type TreeNodeId = string;
export interface ReadOnlyTreeNode { export interface ReadOnlyTreeNode {
readonly id: string; readonly id: TreeNodeId;
readonly state: TreeNodeStateReader; readonly state: TreeNodeStateReader;
readonly hierarchy: HierarchyReader; readonly hierarchy: HierarchyReader;
readonly metadata?: object; readonly metadata?: object;

View File

@@ -1,6 +1,6 @@
import { TreeNodeHierarchy } from './Hierarchy/TreeNodeHierarchy'; import { TreeNodeHierarchy } from './Hierarchy/TreeNodeHierarchy';
import { TreeNodeState } from './State/TreeNodeState'; import { TreeNodeState } from './State/TreeNodeState';
import type { TreeNode } from './TreeNode'; import type { TreeNode, TreeNodeId } from './TreeNode';
import type { TreeNodeStateAccess } from './State/StateAccess'; import type { TreeNodeStateAccess } from './State/StateAccess';
import type { HierarchyAccess } from './Hierarchy/HierarchyAccess'; import type { HierarchyAccess } from './Hierarchy/HierarchyAccess';
@@ -9,7 +9,7 @@ export class TreeNodeManager implements TreeNode {
public readonly hierarchy: HierarchyAccess; public readonly hierarchy: HierarchyAccess;
constructor(public readonly id: string, public readonly metadata?: object) { constructor(public readonly id: TreeNodeId, public readonly metadata?: object) {
if (!id) { if (!id) {
throw new Error('missing id'); throw new Error('missing id');
} }

View File

@@ -1,15 +1,15 @@
import type { ReadOnlyTreeNode, TreeNode } from '../../../Node/TreeNode'; import type { ReadOnlyTreeNode, TreeNode, TreeNodeId } from '../../../Node/TreeNode';
export interface ReadOnlyQueryableNodes { export interface ReadOnlyQueryableNodes {
readonly rootNodes: readonly ReadOnlyTreeNode[]; readonly rootNodes: readonly ReadOnlyTreeNode[];
readonly flattenedNodes: readonly ReadOnlyTreeNode[]; readonly flattenedNodes: readonly ReadOnlyTreeNode[];
getNodeById(id: string): ReadOnlyTreeNode; getNodeById(nodeId: TreeNodeId): ReadOnlyTreeNode;
} }
export interface QueryableNodes extends ReadOnlyQueryableNodes { export interface QueryableNodes extends ReadOnlyQueryableNodes {
readonly rootNodes: readonly TreeNode[]; readonly rootNodes: readonly TreeNode[];
readonly flattenedNodes: readonly TreeNode[]; readonly flattenedNodes: readonly TreeNode[];
getNodeById(id: string): TreeNode; getNodeById(nodeId: TreeNodeId): TreeNode;
} }

View File

@@ -1,5 +1,5 @@
import type { QueryableNodes } from './QueryableNodes'; import type { QueryableNodes } from './QueryableNodes';
import type { TreeNode } from '../../../Node/TreeNode'; import type { TreeNode, TreeNodeId } from '../../../Node/TreeNode';
export class TreeNodeNavigator implements QueryableNodes { export class TreeNodeNavigator implements QueryableNodes {
public readonly flattenedNodes: readonly TreeNode[]; public readonly flattenedNodes: readonly TreeNode[];
@@ -8,10 +8,10 @@ export class TreeNodeNavigator implements QueryableNodes {
this.flattenedNodes = flattenNodes(rootNodes); this.flattenedNodes = flattenNodes(rootNodes);
} }
public getNodeById(id: string): TreeNode { public getNodeById(nodeId: TreeNodeId): TreeNode {
const foundNode = this.flattenedNodes.find((node) => node.id === id); const foundNode = this.flattenedNodes.find((node) => node.id === nodeId);
if (!foundNode) { if (!foundNode) {
throw new Error(`Node could not be found: ${id}`); throw new Error(`Node could not be found: ${nodeId}`);
} }
return foundNode; return foundNode;
} }

View File

@@ -22,6 +22,7 @@ import {
} from 'vue'; } from 'vue';
import HierarchicalTreeNode from '../Node/HierarchicalTreeNode.vue'; import HierarchicalTreeNode from '../Node/HierarchicalTreeNode.vue';
import { useCurrentTreeNodes } from '../UseCurrentTreeNodes'; import { useCurrentTreeNodes } from '../UseCurrentTreeNodes';
import { type TreeNodeId } from '../Node/TreeNode';
import type { NodeRenderingStrategy } from '../Rendering/Scheduling/NodeRenderingStrategy'; import type { NodeRenderingStrategy } from '../Rendering/Scheduling/NodeRenderingStrategy';
import type { TreeRoot } from './TreeRoot'; import type { TreeRoot } from './TreeRoot';
import type { PropType } from 'vue'; import type { PropType } from 'vue';
@@ -43,7 +44,7 @@ export default defineComponent({
setup(props) { setup(props) {
const { nodes } = useCurrentTreeNodes(toRef(props, 'treeRoot')); const { nodes } = useCurrentTreeNodes(toRef(props, 'treeRoot'));
const renderedNodeIds = computed<string[]>(() => { const renderedNodeIds = computed<TreeNodeId[]>(() => {
return nodes return nodes
.value .value
.rootNodes .rootNodes

View File

@@ -26,6 +26,7 @@ import { useLeafNodeCheckedStateUpdater } from './UseLeafNodeCheckedStateUpdater
import { useAutoUpdateParentCheckState } from './UseAutoUpdateParentCheckState'; import { useAutoUpdateParentCheckState } from './UseAutoUpdateParentCheckState';
import { useAutoUpdateChildrenCheckState } from './UseAutoUpdateChildrenCheckState'; import { useAutoUpdateChildrenCheckState } from './UseAutoUpdateChildrenCheckState';
import { useGradualNodeRendering, type NodeRenderingControl } from './Rendering/UseGradualNodeRendering'; import { useGradualNodeRendering, type NodeRenderingControl } from './Rendering/UseGradualNodeRendering';
import { type TreeNodeId } from './Node/TreeNode';
import type { TreeNodeStateChangedEmittedEvent } from './Bindings/TreeNodeStateChangedEmittedEvent'; import type { TreeNodeStateChangedEmittedEvent } from './Bindings/TreeNodeStateChangedEmittedEvent';
import type { TreeInputNodeData } from './Bindings/TreeInputNodeData'; import type { TreeInputNodeData } from './Bindings/TreeInputNodeData';
import type { TreeViewFilterEvent } from './Bindings/TreeInputFilterEvent'; import type { TreeViewFilterEvent } from './Bindings/TreeInputFilterEvent';
@@ -45,7 +46,7 @@ export default defineComponent({
default: () => undefined, default: () => undefined,
}, },
selectedLeafNodeIds: { selectedLeafNodeIds: {
type: Array as PropType<ReadonlyArray<string>>, type: Array as PropType<ReadonlyArray<TreeNodeId>>,
default: () => [], default: () => [],
}, },
}, },

View File

@@ -1,14 +1,17 @@
import type { Category } from '@/domain/Executables/Category/Category'; import type { Category } from '@/domain/Executables/Category/Category';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import type { Script } from '@/domain/Executables/Script/Script'; import type { Script } from '@/domain/Executables/Script/Script';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import type { Executable } from '@/domain/Executables/Executable';
import { type NodeMetadata, NodeType } from '../NodeContent/NodeMetadata'; import { type NodeMetadata, NodeType } from '../NodeContent/NodeMetadata';
import type { TreeNodeId } from '../TreeView/Node/TreeNode';
export function parseAllCategories(collection: ICategoryCollection): NodeMetadata[] { export function parseAllCategories(collection: ICategoryCollection): NodeMetadata[] {
return createCategoryNodes(collection.actions); return createCategoryNodes(collection.actions);
} }
export function parseSingleCategory( export function parseSingleCategory(
categoryId: number, categoryId: ExecutableId,
collection: ICategoryCollection, collection: ICategoryCollection,
): NodeMetadata[] { ): NodeMetadata[] {
const category = collection.getCategory(categoryId); const category = collection.getCategory(categoryId);
@@ -16,27 +19,19 @@ export function parseSingleCategory(
return tree; return tree;
} }
export function getScriptNodeId(script: Script): string { export function createNodeIdForExecutable(executable: Executable): TreeNodeId {
return script.id; return executable.executableId;
} }
export function getScriptId(nodeId: string): string { export function createExecutableIdFromNodeId(nodeId: TreeNodeId): ExecutableId {
return nodeId; return nodeId;
} }
export function getCategoryId(nodeId: string): number {
return +nodeId;
}
export function getCategoryNodeId(category: Category): string {
return `${category.id}`;
}
function parseCategoryRecursively( function parseCategoryRecursively(
parentCategory: Category, parentCategory: Category,
): NodeMetadata[] { ): NodeMetadata[] {
return [ return [
...createCategoryNodes(parentCategory.subCategories), ...createCategoryNodes(parentCategory.subcategories),
...createScriptNodes(parentCategory.scripts), ...createScriptNodes(parentCategory.scripts),
]; ];
} }
@@ -57,7 +52,7 @@ function convertCategoryToNode(
children: readonly NodeMetadata[], children: readonly NodeMetadata[],
): NodeMetadata { ): NodeMetadata {
return { return {
id: getCategoryNodeId(category), executableId: createNodeIdForExecutable(category),
type: NodeType.Category, type: NodeType.Category,
text: category.name, text: category.name,
children, children,
@@ -68,7 +63,7 @@ function convertCategoryToNode(
function convertScriptToNode(script: Script): NodeMetadata { function convertScriptToNode(script: Script): NodeMetadata {
return { return {
id: getScriptNodeId(script), executableId: createNodeIdForExecutable(script),
type: NodeType.Script, type: NodeType.Script,
text: script.name, text: script.name,
children: [], children: [],

View File

@@ -14,7 +14,7 @@ export function getNodeMetadata(
export function convertToNodeInput(metadata: NodeMetadata): TreeInputNodeData { export function convertToNodeInput(metadata: NodeMetadata): TreeInputNodeData {
return { return {
id: metadata.id, id: metadata.executableId,
children: convertChildren(metadata.children, convertToNodeInput), children: convertChildren(metadata.children, convertToNodeInput),
data: metadata, data: metadata,
}; };

View File

@@ -2,20 +2,21 @@ import {
computed, shallowReadonly, computed, shallowReadonly,
} from 'vue'; } from 'vue';
import type { useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState'; import type { useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
import { getScriptNodeId } from './CategoryNodeMetadataConverter'; import { createNodeIdForExecutable } from './CategoryNodeMetadataConverter';
import type { TreeNodeId } from '../TreeView/Node/TreeNode';
export function useSelectedScriptNodeIds( export function useSelectedScriptNodeIds(
useSelectionStateHook: ReturnType<typeof useUserSelectionState>, useSelectionStateHook: ReturnType<typeof useUserSelectionState>,
scriptNodeIdParser = getScriptNodeId, convertToNodeId = createNodeIdForExecutable,
) { ) {
const { currentSelection } = useSelectionStateHook; const { currentSelection } = useSelectionStateHook;
const selectedNodeIds = computed<readonly string[]>(() => { const selectedNodeIds = computed<readonly TreeNodeId[]>(() => {
return currentSelection return currentSelection
.value .value
.scripts .scripts
.selectedScripts .selectedScripts
.map((selected) => scriptNodeIdParser(selected.script)); .map((selected) => convertToNodeId(selected.script));
}); });
return { return {

View File

@@ -1,16 +1,15 @@
import { import {
type Ref, shallowReadonly, shallowRef, type Ref, shallowReadonly, shallowRef,
} from 'vue'; } from 'vue';
import type { Script } from '@/domain/Executables/Script/Script';
import type { Category } from '@/domain/Executables/Category/Category'; import type { Category } from '@/domain/Executables/Category/Category';
import { injectKey } from '@/presentation/injectionSymbols'; import { injectKey } from '@/presentation/injectionSymbols';
import type { ReadonlyFilterContext } from '@/application/Context/State/Filter/FilterContext'; import type { ReadonlyFilterContext } from '@/application/Context/State/Filter/FilterContext';
import type { FilterResult } from '@/application/Context/State/Filter/Result/FilterResult'; import type { FilterResult } from '@/application/Context/State/Filter/Result/FilterResult';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import type { Executable } from '@/domain/Executables/Executable';
import { type TreeViewFilterEvent, createFilterRemovedEvent, createFilterTriggeredEvent } from '../TreeView/Bindings/TreeInputFilterEvent'; import { type TreeViewFilterEvent, createFilterRemovedEvent, createFilterTriggeredEvent } from '../TreeView/Bindings/TreeInputFilterEvent';
import { getNodeMetadata } from './TreeNodeMetadataConverter'; import { createExecutableIdFromNodeId } from './CategoryNodeMetadataConverter';
import { getCategoryNodeId, getScriptNodeId } from './CategoryNodeMetadataConverter'; import type { ReadOnlyTreeNode, TreeNodeId } from '../TreeView/Node/TreeNode';
import type { NodeMetadata } from '../NodeContent/NodeMetadata';
import type { ReadOnlyTreeNode } from '../TreeView/Node/TreeNode';
type TreeNodeFilterResultPredicate = ( type TreeNodeFilterResultPredicate = (
node: ReadOnlyTreeNode, node: ReadOnlyTreeNode,
@@ -24,7 +23,7 @@ export function useTreeViewFilterEvent() {
const latestFilterEvent = shallowRef<TreeViewFilterEvent | undefined>(undefined); const latestFilterEvent = shallowRef<TreeViewFilterEvent | undefined>(undefined);
const treeNodePredicate: TreeNodeFilterResultPredicate = (node, filterResult) => filterMatches( const treeNodePredicate: TreeNodeFilterResultPredicate = (node, filterResult) => filterMatches(
getNodeMetadata(node), node.id,
filterResult, filterResult,
); );
@@ -71,15 +70,17 @@ function createFilterEvent(
); );
} }
function filterMatches(node: NodeMetadata, filter: FilterResult): boolean { function filterMatches(nodeId: TreeNodeId, filter: FilterResult): boolean {
return containsScript(node, filter.scriptMatches) const executableId = createExecutableIdFromNodeId(nodeId);
|| containsCategory(node, filter.categoryMatches); return containsExecutable(executableId, filter.scriptMatches)
|| containsExecutable(executableId, filter.categoryMatches);
} }
function containsScript(expected: NodeMetadata, scripts: readonly Script[]) { function containsExecutable(
return scripts.some((existing: Script) => expected.id === getScriptNodeId(existing)); expectedId: ExecutableId,
} executables: readonly Executable[],
): boolean {
function containsCategory(expected: NodeMetadata, categories: readonly Category[]) { return executables.some(
return categories.some((existing: Category) => expected.id === getCategoryNodeId(existing)); (existing: Category) => existing.executableId === expectedId,
);
} }

View File

@@ -1,15 +1,16 @@
import { import {
type Ref, computed, shallowReadonly, type Ref, computed, shallowReadonly,
} from 'vue'; } from 'vue';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { injectKey } from '@/presentation/injectionSymbols'; import { injectKey } from '@/presentation/injectionSymbols';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import { parseSingleCategory, parseAllCategories } from './CategoryNodeMetadataConverter'; import { parseSingleCategory, parseAllCategories } from './CategoryNodeMetadataConverter';
import { convertToNodeInput } from './TreeNodeMetadataConverter'; import { convertToNodeInput } from './TreeNodeMetadataConverter';
import type { TreeInputNodeData } from '../TreeView/Bindings/TreeInputNodeData'; import type { TreeInputNodeData } from '../TreeView/Bindings/TreeInputNodeData';
import type { NodeMetadata } from '../NodeContent/NodeMetadata'; import type { NodeMetadata } from '../NodeContent/NodeMetadata';
export function useTreeViewNodeInput( export function useTreeViewNodeInput(
categoryIdRef: Readonly<Ref<number | undefined>>, categoryIdRef: Readonly<Ref<ExecutableId | undefined>>,
parser: CategoryNodeParser = { parser: CategoryNodeParser = {
parseSingle: parseSingleCategory, parseSingle: parseSingleCategory,
parseAll: parseAllCategories, parseAll: parseAllCategories,
@@ -30,7 +31,7 @@ export function useTreeViewNodeInput(
} }
function parseNodes( function parseNodes(
categoryId: number | undefined, categoryId: ExecutableId | undefined,
categoryCollection: ICategoryCollection, categoryCollection: ICategoryCollection,
parser: CategoryNodeParser, parser: CategoryNodeParser,
): NodeMetadata[] { ): NodeMetadata[] {

View File

@@ -225,7 +225,7 @@ function collectAllDocumentedExecutables(): DocumentedExecutable[] {
]); ]);
const allDocumentedExecutables = allExecutables.filter((e) => e.docs.length > 0); const allDocumentedExecutables = allExecutables.filter((e) => e.docs.length > 0);
return allDocumentedExecutables.map((executable): DocumentedExecutable => ({ return allDocumentedExecutables.map((executable): DocumentedExecutable => ({
executableLabel: `${executable.name} (${executable.id})`, executableLabel: `${executable.name} (${executable.executableId})`,
docs: executable.docs.join('\n'), docs: executable.docs.join('\n'),
})); }));
} }

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { buildContext } from '@/application/Context/ApplicationContextFactory'; import { buildContext } from '@/application/Context/ApplicationContextFactory';
import type { IApplicationFactory } from '@/application/IApplicationFactory'; import type { IApplicationFactory } from '@/application/IApplicationFactory';
import type { IApplication } from '@/domain/IApplication'; import type { IApplication } from '@/domain/IApplication';

View File

@@ -4,7 +4,7 @@ import { CategoryCollectionState } from '@/application/Context/State/CategoryCol
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub'; import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
import { ScriptingDefinitionStub } from '@tests/unit/shared/Stubs/ScriptingDefinitionStub'; import { ScriptingDefinitionStub } from '@tests/unit/shared/Stubs/ScriptingDefinitionStub';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { ApplicationCodeStub } from '@tests/unit/shared/Stubs/ApplicationCodeStub'; import { ApplicationCodeStub } from '@tests/unit/shared/Stubs/ApplicationCodeStub';
import type { IScriptingDefinition } from '@/domain/IScriptingDefinition'; import type { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { expectExists } from '@tests/shared/Assertions/ExpectExists'; import { expectExists } from '@tests/shared/Assertions/ExpectExists';

View File

@@ -8,7 +8,7 @@ import { FilterResultStub } from '@tests/unit/shared/Stubs/FilterResultStub';
import { FilterStrategyStub } from '@tests/unit/shared/Stubs/FilterStrategyStub'; import { FilterStrategyStub } from '@tests/unit/shared/Stubs/FilterStrategyStub';
import type { FilterStrategy } from '@/application/Context/State/Filter/Strategy/FilterStrategy'; import type { FilterStrategy } from '@/application/Context/State/Filter/Strategy/FilterStrategy';
import type { FilterResult } from '@/application/Context/State/Filter/Result/FilterResult'; import type { FilterResult } from '@/application/Context/State/Filter/Result/FilterResult';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
describe('AdaptiveFilterContext', () => { describe('AdaptiveFilterContext', () => {
describe('clearFilter', () => { describe('clearFilter', () => {

View File

@@ -36,7 +36,7 @@ describe('AppliedFilterResult', () => {
// arrange // arrange
const expected = true; const expected = true;
const result = new ResultBuilder() const result = new ResultBuilder()
.withScriptMatches([new ScriptStub('id')]) .withScriptMatches([new ScriptStub('matched-script')])
.withCategoryMatches([]) .withCategoryMatches([])
.build(); .build();
// act // act
@@ -48,7 +48,7 @@ describe('AppliedFilterResult', () => {
const expected = true; const expected = true;
const result = new ResultBuilder() const result = new ResultBuilder()
.withScriptMatches([]) .withScriptMatches([])
.withCategoryMatches([new CategoryStub(5)]) .withCategoryMatches([new CategoryStub('matched-category')])
.build(); .build();
// act // act
const actual = result.hasAnyMatches(); const actual = result.hasAnyMatches();
@@ -58,8 +58,8 @@ describe('AppliedFilterResult', () => {
// arrange // arrange
const expected = true; const expected = true;
const result = new ResultBuilder() const result = new ResultBuilder()
.withScriptMatches([new ScriptStub('id')]) .withScriptMatches([new ScriptStub('matched-script')])
.withCategoryMatches([new CategoryStub(5)]) .withCategoryMatches([new CategoryStub('matched-category')])
.build(); .build();
// act // act
const actual = result.hasAnyMatches(); const actual = result.hasAnyMatches();
@@ -69,9 +69,13 @@ describe('AppliedFilterResult', () => {
}); });
class ResultBuilder { class ResultBuilder {
private scriptMatches: readonly Script[] = [new ScriptStub('id')]; private scriptMatches: readonly Script[] = [
new ScriptStub(`[${ResultBuilder.name}]matched-script`),
];
private categoryMatches: readonly Category[] = [new CategoryStub(5)]; private categoryMatches: readonly Category[] = [
new CategoryStub(`[${ResultBuilder.name}]matched-category`),
];
private query: string = `[${ResultBuilder.name}]query`; private query: string = `[${ResultBuilder.name}]query`;

View File

@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub'; import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub'; import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub'; import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import type { Category } from '@/domain/Executables/Category/Category'; import type { Category } from '@/domain/Executables/Category/Category';
import type { Script } from '@/domain/Executables/Script/Script'; import type { Script } from '@/domain/Executables/Script/Script';
import type { FilterResult } from '@/application/Context/State/Filter/Result/FilterResult'; import type { FilterResult } from '@/application/Context/State/Filter/Result/FilterResult';
@@ -37,7 +37,10 @@ describe('LinearFilterStrategy', () => {
// arrange // arrange
const matchingFilter = 'matching filter'; const matchingFilter = 'matching filter';
const collection = new CategoryCollectionStub() const collection = new CategoryCollectionStub()
.withAction(new CategoryStub(2).withScript(createMatchingScript(matchingFilter))); .withAction(
new CategoryStub('parent-category-of-matching-script')
.withScript(createMatchingScript(matchingFilter)),
);
const strategy = new FilterStrategyTestBuilder() const strategy = new FilterStrategyTestBuilder()
.withFilter(matchingFilter) .withFilter(matchingFilter)
.withCollection(collection); .withCollection(collection);
@@ -64,7 +67,7 @@ describe('LinearFilterStrategy', () => {
const matchingFilter = 'matching filter'; const matchingFilter = 'matching filter';
const collection = new CategoryCollectionStub() const collection = new CategoryCollectionStub()
.withAction(createMatchingCategory(matchingFilter)) .withAction(createMatchingCategory(matchingFilter))
.withAction(new CategoryStub(2).withScript(createMatchingScript(matchingFilter))); .withAction(new CategoryStub('matching-script-parent').withScript(createMatchingScript(matchingFilter)));
const strategy = new FilterStrategyTestBuilder() const strategy = new FilterStrategyTestBuilder()
.withFilter(matchingFilter) .withFilter(matchingFilter)
.withCollection(collection); .withCollection(collection);
@@ -120,7 +123,7 @@ describe('LinearFilterStrategy', () => {
// arrange // arrange
const expectedMatches = [matchingScript]; const expectedMatches = [matchingScript];
const collection = new CategoryCollectionStub() const collection = new CategoryCollectionStub()
.withAction(new CategoryStub(33).withScript(matchingScript)); .withAction(new CategoryStub('matching-script-parent').withScript(matchingScript));
const strategy = new FilterStrategyTestBuilder() const strategy = new FilterStrategyTestBuilder()
.withFilter(filter) .withFilter(filter)
.withCollection(collection); .withCollection(collection);
@@ -140,7 +143,7 @@ describe('LinearFilterStrategy', () => {
]; ];
const expectedMatches = [...matchingScripts]; const expectedMatches = [...matchingScripts];
const collection = new CategoryCollectionStub() const collection = new CategoryCollectionStub()
.withAction(new CategoryStub(0).withScripts(...matchingScripts)); .withAction(new CategoryStub('matching-scripts-parent').withScripts(...matchingScripts));
const strategy = new FilterStrategyTestBuilder() const strategy = new FilterStrategyTestBuilder()
.withFilter(filter) .withFilter(filter)
.withCollection(collection); .withCollection(collection);
@@ -171,12 +174,12 @@ describe('LinearFilterStrategy', () => {
{ {
description: 'match with case-insensitive name', description: 'match with case-insensitive name',
filter: 'Hello WoRLD', filter: 'Hello WoRLD',
matchingCategory: new CategoryStub(55).withName('HELLO world'), matchingCategory: new CategoryStub('matching-script-parent').withName('HELLO world'),
}, },
{ {
description: 'case-sensitive documentation', description: 'case-sensitive documentation',
filter: 'Hello WoRLD', filter: 'Hello WoRLD',
matchingCategory: new CategoryStub(55).withDocs(['unrelated-docs', 'HELLO world']), matchingCategory: new CategoryStub('matching-script-parent').withDocs(['unrelated-docs', 'HELLO world']),
}, },
]; ];
testScenarios.forEach(({ testScenarios.forEach(({
@@ -230,7 +233,7 @@ function createMatchingScript(
function createMatchingCategory( function createMatchingCategory(
matchingFilter: string, matchingFilter: string,
): CategoryStub { ): CategoryStub {
return new CategoryStub(1) return new CategoryStub('matching-category')
.withName(matchingFilter) .withName(matchingFilter)
.withDocs([matchingFilter]); .withDocs([matchingFilter]);
} }

View File

@@ -2,14 +2,13 @@ import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub'; import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub'; import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import type { ScriptSelection } from '@/application/Context/State/Selection/Script/ScriptSelection'; import type { ScriptSelection } from '@/application/Context/State/Selection/Script/ScriptSelection';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { ScriptToCategorySelectionMapper } from '@/application/Context/State/Selection/Category/ScriptToCategorySelectionMapper'; import { ScriptToCategorySelectionMapper } from '@/application/Context/State/Selection/Category/ScriptToCategorySelectionMapper';
import { ScriptSelectionStub } from '@tests/unit/shared/Stubs/ScriptSelectionStub'; import { ScriptSelectionStub } from '@tests/unit/shared/Stubs/ScriptSelectionStub';
import type { CategorySelectionChange } from '@/application/Context/State/Selection/Category/CategorySelectionChange'; import type { CategorySelectionChange } from '@/application/Context/State/Selection/Category/CategorySelectionChange';
import type { ScriptSelectionChange, ScriptSelectionChangeCommand } from '@/application/Context/State/Selection/Script/ScriptSelectionChange'; import type { ScriptSelectionChange, ScriptSelectionChangeCommand } from '@/application/Context/State/Selection/Script/ScriptSelectionChange';
import { expectExists } from '@tests/shared/Assertions/ExpectExists'; import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import type { Category } from '@/domain/Executables/Category/Category'; import type { ExecutableId } from '@/domain/Executables/Identifiable';
import type { Script } from '@/domain/Executables/Script/Script';
describe('ScriptToCategorySelectionMapper', () => { describe('ScriptToCategorySelectionMapper', () => {
describe('areAllScriptsSelected', () => { describe('areAllScriptsSelected', () => {
@@ -65,18 +64,18 @@ describe('ScriptToCategorySelectionMapper', () => {
readonly description: string; readonly description: string;
readonly changes: readonly CategorySelectionChange[]; readonly changes: readonly CategorySelectionChange[];
readonly categories: ReadonlyArray<{ readonly categories: ReadonlyArray<{
readonly categoryId: Category['id'], readonly categoryId: ExecutableId,
readonly scriptIds: readonly Script['id'][], readonly scriptIds: readonly ExecutableId[],
}>; }>;
readonly expected: readonly ScriptSelectionChange[], readonly expected: readonly ScriptSelectionChange[],
}> = [ }> = [
{ {
description: 'single script: select without revert', description: 'single script: select without revert',
categories: [ categories: [
{ categoryId: 1, scriptIds: ['single-script'] }, { categoryId: 'category-1', scriptIds: ['single-script'] },
], ],
changes: [ changes: [
{ categoryId: 1, newStatus: { isSelected: true, isReverted: false } }, { categoryId: 'category-1', newStatus: { isSelected: true, isReverted: false } },
], ],
expected: [ expected: [
{ scriptId: 'single-script', newStatus: { isSelected: true, isReverted: false } }, { scriptId: 'single-script', newStatus: { isSelected: true, isReverted: false } },
@@ -85,12 +84,12 @@ describe('ScriptToCategorySelectionMapper', () => {
{ {
description: 'multiple scripts: select without revert', description: 'multiple scripts: select without revert',
categories: [ categories: [
{ categoryId: 1, scriptIds: ['script1-cat1', 'script2-cat1'] }, { categoryId: 'category-1', scriptIds: ['script1-cat1', 'script2-cat1'] },
{ categoryId: 2, scriptIds: ['script3-cat2'] }, { categoryId: 'category-2', scriptIds: ['script3-cat2'] },
], ],
changes: [ changes: [
{ categoryId: 1, newStatus: { isSelected: true, isReverted: false } }, { categoryId: 'category-1', newStatus: { isSelected: true, isReverted: false } },
{ categoryId: 2, newStatus: { isSelected: true, isReverted: false } }, { categoryId: 'category-2', newStatus: { isSelected: true, isReverted: false } },
], ],
expected: [ expected: [
{ scriptId: 'script1-cat1', newStatus: { isSelected: true, isReverted: false } }, { scriptId: 'script1-cat1', newStatus: { isSelected: true, isReverted: false } },
@@ -101,10 +100,10 @@ describe('ScriptToCategorySelectionMapper', () => {
{ {
description: 'single script: select with revert', description: 'single script: select with revert',
categories: [ categories: [
{ categoryId: 1, scriptIds: ['single-script'] }, { categoryId: 'category-1', scriptIds: ['single-script'] },
], ],
changes: [ changes: [
{ categoryId: 1, newStatus: { isSelected: true, isReverted: true } }, { categoryId: 'category-1', newStatus: { isSelected: true, isReverted: true } },
], ],
expected: [ expected: [
{ scriptId: 'single-script', newStatus: { isSelected: true, isReverted: true } }, { scriptId: 'single-script', newStatus: { isSelected: true, isReverted: true } },
@@ -113,14 +112,14 @@ describe('ScriptToCategorySelectionMapper', () => {
{ {
description: 'multiple scripts: select with revert', description: 'multiple scripts: select with revert',
categories: [ categories: [
{ categoryId: 1, scriptIds: ['script-1-cat-1'] }, { categoryId: 'category-1', scriptIds: ['script-1-cat-1'] },
{ categoryId: 2, scriptIds: ['script-2-cat-2'] }, { categoryId: 'category-2', scriptIds: ['script-2-cat-2'] },
{ categoryId: 3, scriptIds: ['script-3-cat-3'] }, { categoryId: 'category-3', scriptIds: ['script-3-cat-3'] },
], ],
changes: [ changes: [
{ categoryId: 1, newStatus: { isSelected: true, isReverted: true } }, { categoryId: 'category-1', newStatus: { isSelected: true, isReverted: true } },
{ categoryId: 2, newStatus: { isSelected: true, isReverted: true } }, { categoryId: 'category-2', newStatus: { isSelected: true, isReverted: true } },
{ categoryId: 3, newStatus: { isSelected: true, isReverted: true } }, { categoryId: 'category-3', newStatus: { isSelected: true, isReverted: true } },
], ],
expected: [ expected: [
{ scriptId: 'script-1-cat-1', newStatus: { isSelected: true, isReverted: true } }, { scriptId: 'script-1-cat-1', newStatus: { isSelected: true, isReverted: true } },
@@ -131,10 +130,10 @@ describe('ScriptToCategorySelectionMapper', () => {
{ {
description: 'single script: deselect', description: 'single script: deselect',
categories: [ categories: [
{ categoryId: 1, scriptIds: ['single-script'] }, { categoryId: 'category-1', scriptIds: ['single-script'] },
], ],
changes: [ changes: [
{ categoryId: 1, newStatus: { isSelected: false } }, { categoryId: 'category-1', newStatus: { isSelected: false } },
], ],
expected: [ expected: [
{ scriptId: 'single-script', newStatus: { isSelected: false } }, { scriptId: 'single-script', newStatus: { isSelected: false } },
@@ -143,12 +142,12 @@ describe('ScriptToCategorySelectionMapper', () => {
{ {
description: 'multiple scripts: deselect', description: 'multiple scripts: deselect',
categories: [ categories: [
{ categoryId: 1, scriptIds: ['script-1-cat1'] }, { categoryId: 'category-1', scriptIds: ['script-1-cat1'] },
{ categoryId: 2, scriptIds: ['script-2-cat2'] }, { categoryId: 'category-2', scriptIds: ['script-2-cat2'] },
], ],
changes: [ changes: [
{ categoryId: 1, newStatus: { isSelected: false } }, { categoryId: 'category-1', newStatus: { isSelected: false } },
{ categoryId: 2, newStatus: { isSelected: false } }, { categoryId: 'category-2', newStatus: { isSelected: false } },
], ],
expected: [ expected: [
{ scriptId: 'script-1-cat1', newStatus: { isSelected: false } }, { scriptId: 'script-1-cat1', newStatus: { isSelected: false } },
@@ -158,14 +157,14 @@ describe('ScriptToCategorySelectionMapper', () => {
{ {
description: 'mixed operations (select, revert, deselect)', description: 'mixed operations (select, revert, deselect)',
categories: [ categories: [
{ categoryId: 1, scriptIds: ['to-revert'] }, { categoryId: 'category-1', scriptIds: ['to-revert'] },
{ categoryId: 2, scriptIds: ['not-revert'] }, { categoryId: 'category-2', scriptIds: ['not-revert'] },
{ categoryId: 3, scriptIds: ['to-deselect'] }, { categoryId: 'category-3', scriptIds: ['to-deselect'] },
], ],
changes: [ changes: [
{ categoryId: 1, newStatus: { isSelected: true, isReverted: true } }, { categoryId: 'category-1', newStatus: { isSelected: true, isReverted: true } },
{ categoryId: 2, newStatus: { isSelected: true, isReverted: false } }, { categoryId: 'category-2', newStatus: { isSelected: true, isReverted: false } },
{ categoryId: 3, newStatus: { isSelected: false } }, { categoryId: 'category-3', newStatus: { isSelected: false } },
], ],
expected: [ expected: [
{ scriptId: 'to-revert', newStatus: { isSelected: true, isReverted: true } }, { scriptId: 'to-revert', newStatus: { isSelected: true, isReverted: true } },
@@ -176,12 +175,12 @@ describe('ScriptToCategorySelectionMapper', () => {
{ {
description: 'affecting selected categories only', description: 'affecting selected categories only',
categories: [ categories: [
{ categoryId: 1, scriptIds: ['relevant-1', 'relevant-2'] }, { categoryId: 'category-1', scriptIds: ['relevant-1', 'relevant-2'] },
{ categoryId: 2, scriptIds: ['not-relevant-1', 'not-relevant-2'] }, { categoryId: 'category-2', scriptIds: ['not-relevant-1', 'not-relevant-2'] },
{ categoryId: 3, scriptIds: ['not-relevant-3', 'not-relevant-4'] }, { categoryId: 'category-3', scriptIds: ['not-relevant-3', 'not-relevant-4'] },
], ],
changes: [ changes: [
{ categoryId: 1, newStatus: { isSelected: true, isReverted: true } }, { categoryId: 'category-1', newStatus: { isSelected: true, isReverted: true } },
], ],
expected: [ expected: [
{ scriptId: 'relevant-1', newStatus: { isSelected: true, isReverted: true } }, { scriptId: 'relevant-1', newStatus: { isSelected: true, isReverted: true } },
@@ -198,7 +197,7 @@ describe('ScriptToCategorySelectionMapper', () => {
const sut = new ScriptToCategorySelectionMapperBuilder() const sut = new ScriptToCategorySelectionMapperBuilder()
.withScriptSelection(scriptSelectionStub) .withScriptSelection(scriptSelectionStub)
.withCollection(new CategoryCollectionStub().withAction( .withCollection(new CategoryCollectionStub().withAction(
new CategoryStub(99) new CategoryStub('single-parent-category-action')
// Register scripts to test for nested items // Register scripts to test for nested items
.withAllScriptIdsRecursively(...categories.flatMap((c) => c.scriptIds)) .withAllScriptIdsRecursively(...categories.flatMap((c) => c.scriptIds))
.withCategories(...categories.map( .withCategories(...categories.map(
@@ -256,7 +255,7 @@ function setupTestWithPreselectedScripts(options: {
new ScriptStub('third-script'), new ScriptStub('third-script'),
]; ];
const preselectedScripts = options.preselect(allScripts); const preselectedScripts = options.preselect(allScripts);
const category = new CategoryStub(1) const category = new CategoryStub('single-parent-category-action')
.withAllScriptsRecursively(...allScripts); // Register scripts to test for nested items .withAllScriptsRecursively(...allScripts); // Register scripts to test for nested items
const collection = new CategoryCollectionStub().withAction(category); const collection = new CategoryCollectionStub().withAction(category);
const sut = new ScriptToCategorySelectionMapperBuilder() const sut = new ScriptToCategorySelectionMapperBuilder()

View File

@@ -4,7 +4,7 @@ import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub'; import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub'; import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub'; import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript'; import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { BatchedDebounceStub } from '@tests/unit/shared/Stubs/BatchedDebounceStub'; import { BatchedDebounceStub } from '@tests/unit/shared/Stubs/BatchedDebounceStub';
import type { ScriptSelectionChange, ScriptSelectionChangeCommand } from '@/application/Context/State/Selection/Script/ScriptSelectionChange'; import type { ScriptSelectionChange, ScriptSelectionChangeCommand } from '@/application/Context/State/Selection/Script/ScriptSelectionChange';
@@ -104,7 +104,7 @@ describe('DebouncedScriptSelection', () => {
const { scriptSelection, unselectedScripts } = setupTestWithPreselectedScripts({ const { scriptSelection, unselectedScripts } = setupTestWithPreselectedScripts({
preselect: (allScripts) => [allScripts[0]], preselect: (allScripts) => [allScripts[0]],
}); });
const scriptIdToCheck = unselectedScripts[0].id; const scriptIdToCheck = unselectedScripts[0].executableId;
// act // act
const actual = scriptSelection.isSelected(scriptIdToCheck); const actual = scriptSelection.isSelected(scriptIdToCheck);
// assert // assert
@@ -300,7 +300,10 @@ describe('DebouncedScriptSelection', () => {
preselect: (allScripts) => [allScripts[0], allScripts[1]] preselect: (allScripts) => [allScripts[0], allScripts[1]]
.map((s) => s.toSelectedScript()), .map((s) => s.toSelectedScript()),
getChanges: (allScripts) => [ getChanges: (allScripts) => [
{ scriptId: allScripts[2].id, newStatus: { isReverted: true, isSelected: true } }, {
scriptId: allScripts[2].executableId,
newStatus: { isReverted: true, isSelected: true },
},
], ],
getExpectedFinalSelection: (allScripts) => [ getExpectedFinalSelection: (allScripts) => [
allScripts[0].toSelectedScript(), allScripts[0].toSelectedScript(),
@@ -313,7 +316,10 @@ describe('DebouncedScriptSelection', () => {
preselect: (allScripts) => [allScripts[0], allScripts[1]] preselect: (allScripts) => [allScripts[0], allScripts[1]]
.map((s) => s.toSelectedScript()), .map((s) => s.toSelectedScript()),
getChanges: (allScripts) => [ getChanges: (allScripts) => [
{ scriptId: allScripts[2].id, newStatus: { isReverted: false, isSelected: true } }, {
scriptId: allScripts[2].executableId,
newStatus: { isReverted: false, isSelected: true },
},
], ],
getExpectedFinalSelection: (allScripts) => [ getExpectedFinalSelection: (allScripts) => [
allScripts[0].toSelectedScript(), allScripts[0].toSelectedScript(),
@@ -326,7 +332,7 @@ describe('DebouncedScriptSelection', () => {
preselect: (allScripts) => [allScripts[0], allScripts[1]] preselect: (allScripts) => [allScripts[0], allScripts[1]]
.map((s) => s.toSelectedScript()), .map((s) => s.toSelectedScript()),
getChanges: (allScripts) => [ getChanges: (allScripts) => [
{ scriptId: allScripts[0].id, newStatus: { isSelected: false } }, { scriptId: allScripts[0].executableId, newStatus: { isSelected: false } },
], ],
getExpectedFinalSelection: (allScripts) => [ getExpectedFinalSelection: (allScripts) => [
allScripts[1].toSelectedScript(), allScripts[1].toSelectedScript(),
@@ -339,7 +345,10 @@ describe('DebouncedScriptSelection', () => {
allScripts[1].toSelectedScript(), allScripts[1].toSelectedScript(),
], ],
getChanges: (allScripts) => [ getChanges: (allScripts) => [
{ scriptId: allScripts[0].id, newStatus: { isSelected: true, isReverted: true } }, {
scriptId: allScripts[0].executableId,
newStatus: { isSelected: true, isReverted: true },
},
], ],
getExpectedFinalSelection: (allScripts) => [ getExpectedFinalSelection: (allScripts) => [
allScripts[0].toSelectedScript().withRevert(true), allScripts[0].toSelectedScript().withRevert(true),
@@ -353,7 +362,10 @@ describe('DebouncedScriptSelection', () => {
allScripts[1].toSelectedScript(), allScripts[1].toSelectedScript(),
], ],
getChanges: (allScripts) => [ getChanges: (allScripts) => [
{ scriptId: allScripts[0].id, newStatus: { isSelected: true, isReverted: false } }, {
scriptId: allScripts[0].executableId,
newStatus: { isSelected: true, isReverted: false },
},
], ],
getExpectedFinalSelection: (allScripts) => [ getExpectedFinalSelection: (allScripts) => [
allScripts[0].toSelectedScript().withRevert(false), allScripts[0].toSelectedScript().withRevert(false),
@@ -367,9 +379,18 @@ describe('DebouncedScriptSelection', () => {
allScripts[2].toSelectedScript(), // remove allScripts[2].toSelectedScript(), // remove
], ],
getChanges: (allScripts) => [ getChanges: (allScripts) => [
{ scriptId: allScripts[0].id, newStatus: { isSelected: true, isReverted: false } }, {
{ scriptId: allScripts[1].id, newStatus: { isSelected: true, isReverted: true } }, scriptId: allScripts[0].executableId,
{ scriptId: allScripts[2].id, newStatus: { isSelected: false } }, newStatus: { isSelected: true, isReverted: false },
},
{
scriptId: allScripts[1].executableId,
newStatus: { isSelected: true, isReverted: true },
},
{
scriptId: allScripts[2].executableId,
newStatus: { isSelected: false },
},
], ],
getExpectedFinalSelection: (allScripts) => [ getExpectedFinalSelection: (allScripts) => [
allScripts[0].toSelectedScript().withRevert(false), allScripts[0].toSelectedScript().withRevert(false),
@@ -408,7 +429,10 @@ describe('DebouncedScriptSelection', () => {
description: 'does not change selection for an already selected script', description: 'does not change selection for an already selected script',
preselect: (allScripts) => [allScripts[0].toSelectedScript().withRevert(true)], preselect: (allScripts) => [allScripts[0].toSelectedScript().withRevert(true)],
getChanges: (allScripts) => [ getChanges: (allScripts) => [
{ scriptId: allScripts[0].id, newStatus: { isReverted: true, isSelected: true } }, {
scriptId: allScripts[0].executableId,
newStatus: { isReverted: true, isSelected: true },
},
], ],
}, },
{ {
@@ -416,15 +440,21 @@ describe('DebouncedScriptSelection', () => {
preselect: (allScripts) => [allScripts[0], allScripts[1]] preselect: (allScripts) => [allScripts[0], allScripts[1]]
.map((s) => s.toSelectedScript()), .map((s) => s.toSelectedScript()),
getChanges: (allScripts) => [ getChanges: (allScripts) => [
{ scriptId: allScripts[2].id, newStatus: { isSelected: false } }, { scriptId: allScripts[2].executableId, newStatus: { isSelected: false } },
], ],
}, },
{ {
description: 'handles no mutations for mixed unchanged operations', description: 'handles no mutations for mixed unchanged operations',
preselect: (allScripts) => [allScripts[0].toSelectedScript().withRevert(false)], preselect: (allScripts) => [allScripts[0].toSelectedScript().withRevert(false)],
getChanges: (allScripts) => [ getChanges: (allScripts) => [
{ scriptId: allScripts[0].id, newStatus: { isSelected: true, isReverted: false } }, {
{ scriptId: allScripts[1].id, newStatus: { isSelected: false } }, scriptId: allScripts[0].executableId,
newStatus: { isSelected: true, isReverted: false },
},
{
scriptId: allScripts[1].executableId,
newStatus: { isSelected: false },
},
], ],
}, },
]; ];
@@ -459,7 +489,7 @@ describe('DebouncedScriptSelection', () => {
.build(); .build();
const expectedCommand: ScriptSelectionChangeCommand = { const expectedCommand: ScriptSelectionChangeCommand = {
changes: [ changes: [
{ scriptId: script.id, newStatus: { isReverted: true, isSelected: true } }, { scriptId: script.executableId, newStatus: { isReverted: true, isSelected: true } },
], ],
}; };
// act // act
@@ -481,7 +511,7 @@ describe('DebouncedScriptSelection', () => {
// act // act
selection.processChanges({ selection.processChanges({
changes: [ changes: [
{ scriptId: script.id, newStatus: { isReverted: true, isSelected: true } }, { scriptId: script.executableId, newStatus: { isReverted: true, isSelected: true } },
], ],
}); });
// assert // assert
@@ -502,7 +532,7 @@ describe('DebouncedScriptSelection', () => {
// act // act
selection.processChanges({ selection.processChanges({
changes: [ changes: [
{ scriptId: script.id, newStatus: { isReverted: true, isSelected: true } }, { scriptId: script.executableId, newStatus: { isReverted: true, isSelected: true } },
], ],
}); });
debounceStub.execute(); debounceStub.execute();
@@ -525,7 +555,7 @@ describe('DebouncedScriptSelection', () => {
for (const script of scripts) { for (const script of scripts) {
selection.processChanges({ selection.processChanges({
changes: [ changes: [
{ scriptId: script.id, newStatus: { isReverted: true, isSelected: true } }, { scriptId: script.executableId, newStatus: { isReverted: true, isSelected: true } },
], ],
}); });
} }
@@ -539,7 +569,7 @@ describe('DebouncedScriptSelection', () => {
}); });
function createCollectionWithScripts(...scripts: Script[]): CategoryCollectionStub { function createCollectionWithScripts(...scripts: Script[]): CategoryCollectionStub {
const category = new CategoryStub(1).withScripts(...scripts); const category = new CategoryStub('parent-category').withScripts(...scripts);
const collection = new CategoryCollectionStub().withAction(category); const collection = new CategoryCollectionStub().withAction(category);
return collection; return collection;
} }
@@ -572,7 +602,7 @@ function setupTestWithPreselectedScripts(options: {
return initialSelection; return initialSelection;
})(); })();
const unselectedScripts = allScripts.filter( const unselectedScripts = allScripts.filter(
(s) => !preselectedScripts.map((selected) => selected.id).includes(s.id), (s) => !preselectedScripts.map((selected) => selected.id).includes(s.executableId),
); );
const collection = createCollectionWithScripts(...allScripts); const collection = createCollectionWithScripts(...allScripts);
const scriptSelection = new DebouncedScriptSelectionBuilder() const scriptSelection = new DebouncedScriptSelectionBuilder()

View File

@@ -1,7 +1,7 @@
import { describe, it } from 'vitest'; import { describe, it } from 'vitest';
import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript'; import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { UserSelectionFacade } from '@/application/Context/State/Selection/UserSelectionFacade'; import { UserSelectionFacade } from '@/application/Context/State/Selection/UserSelectionFacade';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub'; import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
import type { ScriptsFactory, CategoriesFactory } from '@/application/Context/State/Selection/UserSelectionFacade'; import type { ScriptsFactory, CategoriesFactory } from '@/application/Context/State/Selection/UserSelectionFacade';
import { ScriptSelectionStub } from '@tests/unit/shared/Stubs/ScriptSelectionStub'; import { ScriptSelectionStub } from '@tests/unit/shared/Stubs/ScriptSelectionStub';

View File

@@ -14,7 +14,7 @@ import { ProjectDetailsStub } from '@tests/unit/shared/Stubs/ProjectDetailsStub'
import type { CategoryCollectionParser } from '@/application/Parser/CategoryCollectionParser'; import type { CategoryCollectionParser } from '@/application/Parser/CategoryCollectionParser';
import type { NonEmptyCollectionAssertion, TypeValidator } from '@/application/Parser/Common/TypeValidator'; import type { NonEmptyCollectionAssertion, TypeValidator } from '@/application/Parser/Common/TypeValidator';
import { TypeValidatorStub } from '@tests/unit/shared/Stubs/TypeValidatorStub'; import { TypeValidatorStub } from '@tests/unit/shared/Stubs/TypeValidatorStub';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
describe('ApplicationParser', () => { describe('ApplicationParser', () => {
describe('parseApplication', () => { describe('parseApplication', () => {

View File

@@ -66,7 +66,10 @@ describe('CategoryCollectionParser', () => {
getInitParameters, getInitParameters,
} = createCategoryCollectionFactorySpy(); } = createCategoryCollectionFactorySpy();
const actionsData = [getCategoryStub('test1'), getCategoryStub('test2')]; const actionsData = [getCategoryStub('test1'), getCategoryStub('test2')];
const expectedActions = [new CategoryStub(1), new CategoryStub(2)]; const expectedActions = [
new CategoryStub('expected-action-1'),
new CategoryStub('expected-action-2'),
];
const categoryParserStub = new CategoryParserStub() const categoryParserStub = new CategoryParserStub()
.withConfiguredParseResult(actionsData[0], expectedActions[0]) .withConfiguredParseResult(actionsData[0], expectedActions[0])
.withConfiguredParseResult(actionsData[1], expectedActions[1]); .withConfiguredParseResult(actionsData[1], expectedActions[1]);

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import type { CategoryData, ExecutableData } from '@/application/collections/'; import type { CategoryData, ExecutableData } from '@/application/collections/';
import { type CategoryFactory, parseCategory } from '@/application/Parser/Executable/CategoryParser'; import { parseCategory } from '@/application/Parser/Executable/CategoryParser';
import { type ScriptParser } from '@/application/Parser/Executable/Script/ScriptParser'; import { type ScriptParser } from '@/application/Parser/Executable/Script/ScriptParser';
import { type DocsParser } from '@/application/Parser/Executable/DocumentationParser'; import { type DocsParser } from '@/application/Parser/Executable/DocumentationParser';
import { CategoryCollectionSpecificUtilitiesStub } from '@tests/unit/shared/Stubs/CategoryCollectionSpecificUtilitiesStub'; import { CategoryCollectionSpecificUtilitiesStub } from '@tests/unit/shared/Stubs/CategoryCollectionSpecificUtilitiesStub';
@@ -20,14 +20,48 @@ import { ScriptParserStub } from '@tests/unit/shared/Stubs/ScriptParserStub';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import type { NonEmptyCollectionAssertion, ObjectAssertion } from '@/application/Parser/Common/TypeValidator'; import type { NonEmptyCollectionAssertion, ObjectAssertion } from '@/application/Parser/Common/TypeValidator';
import { indentText } from '@/application/Common/Text/IndentText'; import { indentText } from '@/application/Common/Text/IndentText';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import type { CategoryFactory } from '@/domain/Executables/Category/CategoryFactory';
import { itThrowsContextualError } from '../Common/ContextualErrorTester'; import { itThrowsContextualError } from '../Common/ContextualErrorTester';
import { itValidatesName, itValidatesType, itAsserts } from './Validation/ExecutableValidationTester'; import { itValidatesName, itValidatesType, itAsserts } from './Validation/ExecutableValidationTester';
import { generateDataValidationTestScenarios } from './Validation/DataValidationTestScenarioGenerator'; import { generateDataValidationTestScenarios } from './Validation/DataValidationTestScenarioGenerator';
describe('CategoryParser', () => { describe('CategoryParser', () => {
describe('parseCategory', () => { describe('parseCategory', () => {
describe('validation', () => { describe('id', () => {
describe('validates for name', () => { it('creates ID correctly', () => {
// arrange
const expectedId: ExecutableId = 'expected-id';
const categoryData = new CategoryDataStub()
.withName(expectedId);
const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy();
// act
const actualScript = new TestContext()
.withData(categoryData)
.withCategoryFactory(categoryFactorySpy)
.parseCategory();
// assert
const actualId = getInitParameters(actualScript)?.executableId;
expect(actualId).to.equal(expectedId);
});
});
describe('name', () => {
it('parses name correctly', () => {
// arrange
const expectedName = 'test-expected-name';
const categoryData = new CategoryDataStub()
.withName(expectedName);
const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy();
// act
const actualCategory = new TestContext()
.withData(categoryData)
.withCategoryFactory(categoryFactorySpy)
.parseCategory();
// assert
const actualName = getInitParameters(actualCategory)?.name;
expect(actualName).to.equal(expectedName);
});
describe('validates name', () => {
// arrange // arrange
const expectedName = 'expected category name to be validated'; const expectedName = 'expected category name to be validated';
const category = new CategoryDataStub() const category = new CategoryDataStub()
@@ -38,7 +72,7 @@ describe('CategoryParser', () => {
}; };
itValidatesName((validatorFactory) => { itValidatesName((validatorFactory) => {
// act // act
new TestBuilder() new TestContext()
.withData(category) .withData(category)
.withValidatorFactory(validatorFactory) .withValidatorFactory(validatorFactory)
.parseCategory(); .parseCategory();
@@ -49,7 +83,33 @@ describe('CategoryParser', () => {
}; };
}); });
}); });
describe('validates for unknown object', () => { });
describe('docs', () => {
it('parses docs correctly', () => {
// arrange
const url = 'https://privacy.sexy';
const categoryData = new CategoryDataStub()
.withDocs(url);
const parseDocs: DocsParser = (data) => {
return [
`parsed docs: ${JSON.stringify(data)}`,
];
};
const expectedDocs = parseDocs(categoryData);
const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy();
// act
const actualCategory = new TestContext()
.withData(categoryData)
.withCategoryFactory(categoryFactorySpy)
.withDocsParser(parseDocs)
.parseCategory();
// assert
const actualDocs = getInitParameters(actualCategory)?.docs;
expect(actualDocs).to.deep.equal(expectedDocs);
});
});
describe('property validation', () => {
describe('validates for unknown executable', () => {
// arrange // arrange
const category = new CategoryDataStub(); const category = new CategoryDataStub();
const expectedContext: CategoryErrorContext = { const expectedContext: CategoryErrorContext = {
@@ -63,7 +123,7 @@ describe('CategoryParser', () => {
itValidatesType( itValidatesType(
(validatorFactory) => { (validatorFactory) => {
// act // act
new TestBuilder() new TestContext()
.withData(category) .withData(category)
.withValidatorFactory(validatorFactory) .withValidatorFactory(validatorFactory)
.parseCategory(); .parseCategory();
@@ -90,7 +150,7 @@ describe('CategoryParser', () => {
itValidatesType( itValidatesType(
(validatorFactory) => { (validatorFactory) => {
// act // act
new TestBuilder() new TestContext()
.withData(category) .withData(category)
.withValidatorFactory(validatorFactory) .withValidatorFactory(validatorFactory)
.parseCategory(); .parseCategory();
@@ -102,6 +162,8 @@ describe('CategoryParser', () => {
}, },
); );
}); });
});
describe('children', () => {
describe('validates children for non-empty collection', () => { describe('validates children for non-empty collection', () => {
// arrange // arrange
const category = new CategoryDataStub() const category = new CategoryDataStub()
@@ -117,7 +179,7 @@ describe('CategoryParser', () => {
itValidatesType( itValidatesType(
(validatorFactory) => { (validatorFactory) => {
// act // act
new TestBuilder() new TestContext()
.withData(category) .withData(category)
.withValidatorFactory(validatorFactory) .withValidatorFactory(validatorFactory)
.parseCategory(); .parseCategory();
@@ -167,7 +229,7 @@ describe('CategoryParser', () => {
parentCategory: parent, parentCategory: parent,
}; };
// act // act
new TestBuilder() new TestContext()
.withData(parent) .withData(parent)
.withValidatorFactory(validatorFactory) .withValidatorFactory(validatorFactory)
.parseCategory(); .parseCategory();
@@ -201,7 +263,7 @@ describe('CategoryParser', () => {
itValidatesType( itValidatesType(
(validatorFactory) => { (validatorFactory) => {
// act // act
new TestBuilder() new TestContext()
.withData(parent) .withData(parent)
.withValidatorFactory(validatorFactory) .withValidatorFactory(validatorFactory)
.parseCategory(); .parseCategory();
@@ -231,7 +293,7 @@ describe('CategoryParser', () => {
}; };
itValidatesName((validatorFactory) => { itValidatesName((validatorFactory) => {
// act // act
new TestBuilder() new TestContext()
.withData(parent) .withData(parent)
.withValidatorFactory(validatorFactory) .withValidatorFactory(validatorFactory)
.parseCategory(); .parseCategory();
@@ -243,54 +305,7 @@ describe('CategoryParser', () => {
}); });
}); });
}); });
}); describe('parses correct subscript', () => {
describe('rethrows exception if category factory fails', () => {
// arrange
const givenData = new CategoryDataStub();
const expectedContextMessage = 'Failed to parse category.';
const expectedError = new Error();
// act & assert
itThrowsContextualError({
throwingAction: (wrapError) => {
const validatorStub = new ExecutableValidatorStub();
validatorStub.createContextualErrorMessage = (message) => message;
const factoryMock: CategoryFactory = () => {
throw expectedError;
};
new TestBuilder()
.withCategoryFactory(factoryMock)
.withValidatorFactory(() => validatorStub)
.withErrorWrapper(wrapError)
.withData(givenData)
.parseCategory();
},
expectedWrappedError: expectedError,
expectedContextMessage,
});
});
it('parses docs correctly', () => {
// arrange
const url = 'https://privacy.sexy';
const categoryData = new CategoryDataStub()
.withDocs(url);
const parseDocs: DocsParser = (data) => {
return [
`parsed docs: ${JSON.stringify(data)}`,
];
};
const expectedDocs = parseDocs(categoryData);
const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy();
// act
const actualCategory = new TestBuilder()
.withData(categoryData)
.withCategoryFactory(categoryFactorySpy)
.withDocsParser(parseDocs)
.parseCategory();
// assert
const actualDocs = getInitParameters(actualCategory)?.docs;
expect(actualDocs).to.deep.equal(expectedDocs);
});
describe('parses expected subscript', () => {
it('parses single script correctly', () => { it('parses single script correctly', () => {
// arrange // arrange
const expectedScript = new ScriptStub('expected script'); const expectedScript = new ScriptStub('expected script');
@@ -301,7 +316,7 @@ describe('CategoryParser', () => {
scriptParser.setupParsedResultForData(childScriptData, expectedScript); scriptParser.setupParsedResultForData(childScriptData, expectedScript);
const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy(); const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy();
// act // act
const actualCategory = new TestBuilder() const actualCategory = new TestContext()
.withData(categoryData) .withData(categoryData)
.withScriptParser(scriptParser.get()) .withScriptParser(scriptParser.get())
.withCategoryFactory(categoryFactorySpy) .withCategoryFactory(categoryFactorySpy)
@@ -331,7 +346,7 @@ describe('CategoryParser', () => {
.withChildren(childrenData); .withChildren(childrenData);
const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy(); const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy();
// act // act
const actualCategory = new TestBuilder() const actualCategory = new TestContext()
.withScriptParser(scriptParser.get()) .withScriptParser(scriptParser.get())
.withData(categoryData) .withData(categoryData)
.withCategoryFactory(categoryFactorySpy) .withCategoryFactory(categoryFactorySpy)
@@ -355,7 +370,7 @@ describe('CategoryParser', () => {
.withChildren(childrenData); .withChildren(childrenData);
const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy(); const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy();
// act // act
const actualCategory = new TestBuilder() const actualCategory = new TestContext()
.withData(categoryData) .withData(categoryData)
.withCollectionUtilities(expected) .withCollectionUtilities(expected)
.withScriptParser(scriptParser.get()) .withScriptParser(scriptParser.get())
@@ -379,9 +394,9 @@ describe('CategoryParser', () => {
).to.equal(true); ).to.equal(true);
}); });
}); });
it('returns expected subcategories', () => { it('parses correct subcategories', () => {
// arrange // arrange
const expectedChildCategory = new CategoryStub(33); const expectedChildCategory = new CategoryStub('expected-child-category');
const childCategoryData = new CategoryDataStub() const childCategoryData = new CategoryDataStub()
.withName('expected child category') .withName('expected child category')
.withChildren([createScriptDataWithCode()]); .withChildren([createScriptDataWithCode()]);
@@ -390,7 +405,7 @@ describe('CategoryParser', () => {
.withChildren([childCategoryData]); .withChildren([childCategoryData]);
const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy(); const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy();
// act // act
const actualCategory = new TestBuilder() const actualCategory = new TestContext()
.withData(categoryData) .withData(categoryData)
.withCategoryFactory((parameters) => { .withCategoryFactory((parameters) => {
if (parameters.name === childCategoryData.category) { if (parameters.name === childCategoryData.category) {
@@ -406,15 +421,53 @@ describe('CategoryParser', () => {
expect(actualSubcategories[0]).to.equal(expectedChildCategory); expect(actualSubcategories[0]).to.equal(expectedChildCategory);
}); });
}); });
describe('category creation', () => {
it('creates category from the factory', () => {
// arrange
const expectedCategory = new CategoryStub('expected-category');
const categoryFactory: CategoryFactory = () => expectedCategory;
// act
const actualCategory = new TestContext()
.withCategoryFactory(categoryFactory)
.parseCategory();
// assert
expect(actualCategory).to.equal(expectedCategory);
});
describe('rethrows exception if category factory fails', () => {
// arrange
const givenData = new CategoryDataStub();
const expectedContextMessage = 'Failed to parse category.';
const expectedError = new Error();
// act & assert
itThrowsContextualError({
throwingAction: (wrapError) => {
const validatorStub = new ExecutableValidatorStub();
validatorStub.createContextualErrorMessage = (message) => message;
const factoryMock: CategoryFactory = () => {
throw expectedError;
};
new TestContext()
.withCategoryFactory(factoryMock)
.withValidatorFactory(() => validatorStub)
.withErrorWrapper(wrapError)
.withData(givenData)
.parseCategory();
},
expectedWrappedError: expectedError,
expectedContextMessage,
});
});
});
});
}); });
class TestBuilder { class TestContext {
private data: CategoryData = new CategoryDataStub(); private data: CategoryData = new CategoryDataStub();
private collectionUtilities: private collectionUtilities:
CategoryCollectionSpecificUtilitiesStub = new CategoryCollectionSpecificUtilitiesStub(); CategoryCollectionSpecificUtilitiesStub = new CategoryCollectionSpecificUtilitiesStub();
private categoryFactory: CategoryFactory = () => new CategoryStub(33); private categoryFactory: CategoryFactory = createCategoryFactorySpy().categoryFactorySpy;
private errorWrapper: ErrorWithContextWrapper = new ErrorWrapperStub().get(); private errorWrapper: ErrorWithContextWrapper = new ErrorWrapperStub().get();

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import type { ScriptData, CallScriptData, CodeScriptData } from '@/application/collections/'; import type { ScriptData, CallScriptData, CodeScriptData } from '@/application/collections/';
import { parseScript, type ScriptFactory } from '@/application/Parser/Executable/Script/ScriptParser'; import { parseScript } from '@/application/Parser/Executable/Script/ScriptParser';
import { type DocsParser } from '@/application/Parser/Executable/DocumentationParser'; import { type DocsParser } from '@/application/Parser/Executable/DocumentationParser';
import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel'; import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests'; import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
@@ -29,11 +29,151 @@ import { itThrowsContextualError } from '@tests/unit/application/Parser/Common/C
import { CategoryCollectionSpecificUtilitiesStub } from '@tests/unit/shared/Stubs/CategoryCollectionSpecificUtilitiesStub'; import { CategoryCollectionSpecificUtilitiesStub } from '@tests/unit/shared/Stubs/CategoryCollectionSpecificUtilitiesStub';
import type { CategoryCollectionSpecificUtilities } from '@/application/Parser/Executable/CategoryCollectionSpecificUtilities'; import type { CategoryCollectionSpecificUtilities } from '@/application/Parser/Executable/CategoryCollectionSpecificUtilities';
import type { ObjectAssertion } from '@/application/Parser/Common/TypeValidator'; import type { ObjectAssertion } from '@/application/Parser/Common/TypeValidator';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import type { ScriptFactory } from '@/domain/Executables/Script/ScriptFactory';
import { itAsserts, itValidatesType, itValidatesName } from '../Validation/ExecutableValidationTester'; import { itAsserts, itValidatesType, itValidatesName } from '../Validation/ExecutableValidationTester';
import { generateDataValidationTestScenarios } from '../Validation/DataValidationTestScenarioGenerator'; import { generateDataValidationTestScenarios } from '../Validation/DataValidationTestScenarioGenerator';
describe('ScriptParser', () => { describe('ScriptParser', () => {
describe('parseScript', () => { describe('parseScript', () => {
describe('property validation', () => {
describe('validates object', () => {
// arrange
const expectedScript = createScriptDataWithCall();
const expectedContext: ScriptErrorContext = {
type: ExecutableType.Script,
self: expectedScript,
};
const expectedAssertion: ObjectAssertion<CallScriptData & CodeScriptData> = {
value: expectedScript,
valueName: expectedScript.name,
allowedProperties: [
'name', 'recommend', 'code', 'revertCode', 'call', 'docs',
],
};
itValidatesType(
(validatorFactory) => {
// act
new TestContext()
.withData(expectedScript)
.withValidatorFactory(validatorFactory)
.parseScript();
// assert
return {
expectedDataToValidate: expectedScript,
expectedErrorContext: expectedContext,
assertValidation: (validator) => validator.assertObject(expectedAssertion),
};
},
);
});
describe('validates union type', () => {
// arrange
const testScenarios = generateDataValidationTestScenarios<ScriptData>(
{
assertErrorMessage: 'Neither "call" or "code" is defined.',
expectFail: [{
description: 'with no call or code',
data: createScriptDataWithoutCallOrCodes(),
}],
expectPass: [
{
description: 'with call',
data: createScriptDataWithCall(),
},
{
description: 'with code',
data: createScriptDataWithCode(),
},
],
},
{
assertErrorMessage: 'Both "call" and "revertCode" are defined.',
expectFail: [{
description: 'with both call and revertCode',
data: createScriptDataWithCall()
.withRevertCode('revert-code'),
}],
expectPass: [
{
description: 'with call, without revertCode',
data: createScriptDataWithCall()
.withRevertCode(undefined),
},
{
description: 'with revertCode, without call',
data: createScriptDataWithCode()
.withRevertCode('revert code'),
},
],
},
{
assertErrorMessage: 'Both "call" and "code" are defined.',
expectFail: [{
description: 'with both call and code',
data: createScriptDataWithCall()
.withCode('code'),
}],
expectPass: [
{
description: 'with call, without code',
data: createScriptDataWithCall()
.withCode(''),
},
{
description: 'with code, without call',
data: createScriptDataWithCode()
.withCode('code'),
},
],
},
);
testScenarios.forEach(({
description, expectedPass, data: scriptData, expectedMessage,
}) => {
describe(description, () => {
itAsserts({
expectedConditionResult: expectedPass,
test: (validatorFactory) => {
const expectedContext: ScriptErrorContext = {
type: ExecutableType.Script,
self: scriptData,
};
// act
new TestContext()
.withData(scriptData)
.withValidatorFactory(validatorFactory)
.parseScript();
// assert
expectExists(expectedMessage);
return {
expectedErrorMessage: expectedMessage,
expectedErrorContext: expectedContext,
};
},
});
});
});
});
});
describe('id', () => {
it('creates ID correctly', () => {
// arrange
const expectedId: ExecutableId = 'expected-id';
const scriptData = createScriptDataWithCode()
.withName(expectedId);
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
// act
const actualScript = new TestContext()
.withData(scriptData)
.withScriptFactory(scriptFactorySpy)
.parseScript();
// assert
const actualId = getInitParameters(actualScript)?.executableId;
expect(actualId).to.equal(expectedId);
});
});
describe('name', () => {
it('parses name correctly', () => { it('parses name correctly', () => {
// arrange // arrange
const expected = 'test-expected-name'; const expected = 'test-expected-name';
@@ -49,6 +189,30 @@ describe('ScriptParser', () => {
const actualName = getInitParameters(actualScript)?.name; const actualName = getInitParameters(actualScript)?.name;
expect(actualName).to.equal(expected); expect(actualName).to.equal(expected);
}); });
describe('validates name', () => {
// arrange
const expectedName = 'expected script name to be validated';
const script = createScriptDataWithCall()
.withName(expectedName);
const expectedContext: ScriptErrorContext = {
type: ExecutableType.Script,
self: script,
};
itValidatesName((validatorFactory) => {
// act
new TestContext()
.withData(script)
.withValidatorFactory(validatorFactory)
.parseScript();
// assert
return {
expectedNameToValidate: expectedName,
expectedErrorContext: expectedContext,
};
});
});
});
describe('docs', () => {
it('parses docs correctly', () => { it('parses docs correctly', () => {
// arrange // arrange
const expectedDocs = ['https://expected-doc1.com', 'https://expected-doc2.com']; const expectedDocs = ['https://expected-doc1.com', 'https://expected-doc2.com'];
@@ -66,16 +230,6 @@ describe('ScriptParser', () => {
const actualDocs = getInitParameters(actualScript)?.docs; const actualDocs = getInitParameters(actualScript)?.docs;
expect(actualDocs).to.deep.equal(expectedDocs); expect(actualDocs).to.deep.equal(expectedDocs);
}); });
it('gets script from the factory', () => {
// arrange
const expectedScript = new ScriptStub('expected-script');
const scriptFactory: ScriptFactory = () => expectedScript;
// act
const actualScript = new TestContext()
.withScriptFactory(scriptFactory)
.parseScript();
// assert
expect(actualScript).to.equal(expectedScript);
}); });
describe('level', () => { describe('level', () => {
describe('generated `undefined` level if given absent value', () => { describe('generated `undefined` level if given absent value', () => {
@@ -261,147 +415,17 @@ describe('ScriptParser', () => {
}); });
}); });
}); });
describe('validation', () => { describe('script creation', () => {
describe('validates for name', () => { it('creates script from the factory', () => {
// arrange // arrange
const expectedName = 'expected script name to be validated'; const expectedScript = new ScriptStub('expected-script');
const script = createScriptDataWithCall() const scriptFactory: ScriptFactory = () => expectedScript;
.withName(expectedName);
const expectedContext: ScriptErrorContext = {
type: ExecutableType.Script,
self: script,
};
itValidatesName((validatorFactory) => {
// act // act
new TestContext() const actualScript = new TestContext()
.withData(script) .withScriptFactory(scriptFactory)
.withValidatorFactory(validatorFactory)
.parseScript(); .parseScript();
// assert // assert
return { expect(actualScript).to.equal(expectedScript);
expectedNameToValidate: expectedName,
expectedErrorContext: expectedContext,
};
});
});
describe('validates for defined data', () => {
// arrange
const expectedScript = createScriptDataWithCall();
const expectedContext: ScriptErrorContext = {
type: ExecutableType.Script,
self: expectedScript,
};
const expectedAssertion: ObjectAssertion<CallScriptData & CodeScriptData> = {
value: expectedScript,
valueName: expectedScript.name,
allowedProperties: [
'name', 'recommend', 'code', 'revertCode', 'call', 'docs',
],
};
itValidatesType(
(validatorFactory) => {
// act
new TestContext()
.withData(expectedScript)
.withValidatorFactory(validatorFactory)
.parseScript();
// assert
return {
expectedDataToValidate: expectedScript,
expectedErrorContext: expectedContext,
assertValidation: (validator) => validator.assertObject(expectedAssertion),
};
},
);
});
describe('validates data', () => {
// arrange
const testScenarios = generateDataValidationTestScenarios<ScriptData>(
{
assertErrorMessage: 'Neither "call" or "code" is defined.',
expectFail: [{
description: 'with no call or code',
data: createScriptDataWithoutCallOrCodes(),
}],
expectPass: [
{
description: 'with call',
data: createScriptDataWithCall(),
},
{
description: 'with code',
data: createScriptDataWithCode(),
},
],
},
{
assertErrorMessage: 'Both "call" and "revertCode" are defined.',
expectFail: [{
description: 'with both call and revertCode',
data: createScriptDataWithCall()
.withRevertCode('revert-code'),
}],
expectPass: [
{
description: 'with call, without revertCode',
data: createScriptDataWithCall()
.withRevertCode(undefined),
},
{
description: 'with revertCode, without call',
data: createScriptDataWithCode()
.withRevertCode('revert code'),
},
],
},
{
assertErrorMessage: 'Both "call" and "code" are defined.',
expectFail: [{
description: 'with both call and code',
data: createScriptDataWithCall()
.withCode('code'),
}],
expectPass: [
{
description: 'with call, without code',
data: createScriptDataWithCall()
.withCode(''),
},
{
description: 'with code, without call',
data: createScriptDataWithCode()
.withCode('code'),
},
],
},
);
testScenarios.forEach(({
description, expectedPass, data: scriptData, expectedMessage,
}) => {
describe(description, () => {
itAsserts({
expectedConditionResult: expectedPass,
test: (validatorFactory) => {
const expectedContext: ScriptErrorContext = {
type: ExecutableType.Script,
self: scriptData,
};
// act
new TestContext()
.withData(scriptData)
.withValidatorFactory(validatorFactory)
.parseScript();
// assert
expectExists(expectedMessage);
return {
expectedErrorMessage: expectedMessage,
expectedErrorContext: expectedContext,
};
},
});
});
});
});
}); });
describe('rethrows exception if script factory fails', () => { describe('rethrows exception if script factory fails', () => {
// arrange // arrange
@@ -432,6 +456,7 @@ describe('ScriptParser', () => {
}); });
}); });
}); });
});
class TestContext { class TestContext {
private data: ScriptData = createScriptDataWithCode(); private data: ScriptData = createScriptDataWithCode();

View File

@@ -3,7 +3,7 @@ import { Application } from '@/domain/Application';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub'; import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
import { ProjectDetailsStub } from '@tests/unit/shared/Stubs/ProjectDetailsStub'; import { ProjectDetailsStub } from '@tests/unit/shared/Stubs/ProjectDetailsStub';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { getAbsentCollectionTestCases } from '@tests/unit/shared/TestCases/AbsentTests'; import { getAbsentCollectionTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
describe('Application', () => { describe('Application', () => {

View File

@@ -5,7 +5,7 @@ import type { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel'; import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
import { getEnumValues } from '@/application/Common/Enum'; import { getEnumValues } from '@/application/Common/Enum';
import { CategoryCollection } from '@/domain/CategoryCollection'; import { CategoryCollection } from '@/domain/Collection/CategoryCollection';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub'; import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub'; import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import { EnumRangeTestRunner } from '@tests/unit/application/Common/EnumRangeTestRunner'; import { EnumRangeTestRunner } from '@tests/unit/application/Common/EnumRangeTestRunner';
@@ -20,10 +20,10 @@ describe('CategoryCollection', () => {
); );
const toIgnore = new ScriptStub('script-to-ignore').withLevel(undefined); const toIgnore = new ScriptStub('script-to-ignore').withLevel(undefined);
for (const currentLevel of recommendationLevels) { for (const currentLevel of recommendationLevels) {
const category = new CategoryStub(0) const category = new CategoryStub('parent-action')
.withScripts(...scriptsWithLevels) .withScripts(...scriptsWithLevels)
.withScript(toIgnore); .withScript(toIgnore);
const sut = new CategoryCollectionBuilder() const sut = new TestContext()
.withActions([category]) .withActions([category])
.construct(); .construct();
// act // act
@@ -40,12 +40,12 @@ describe('CategoryCollection', () => {
new ScriptStub('S2').withLevel(level), new ScriptStub('S2').withLevel(level),
]; ];
const actions = [ const actions = [
new CategoryStub(3).withScripts( new CategoryStub('parent-category').withScripts(
...expected, ...expected,
new ScriptStub('S3').withLevel(RecommendationLevel.Strict), new ScriptStub('S3').withLevel(RecommendationLevel.Strict),
), ),
]; ];
const sut = new CategoryCollectionBuilder() const sut = new TestContext()
.withActions(actions) .withActions(actions)
.construct(); .construct();
// act // act
@@ -61,9 +61,9 @@ describe('CategoryCollection', () => {
new ScriptStub('S2').withLevel(RecommendationLevel.Strict), new ScriptStub('S2').withLevel(RecommendationLevel.Strict),
]; ];
const actions = [ const actions = [
new CategoryStub(3).withScripts(...expected), new CategoryStub('parent-category').withScripts(...expected),
]; ];
const sut = new CategoryCollectionBuilder() const sut = new TestContext()
.withActions(actions) .withActions(actions)
.construct(); .construct();
// act // act
@@ -74,7 +74,7 @@ describe('CategoryCollection', () => {
describe('throws when given invalid level', () => { describe('throws when given invalid level', () => {
new EnumRangeTestRunner<RecommendationLevel>((level) => { new EnumRangeTestRunner<RecommendationLevel>((level) => {
// arrange // arrange
const sut = new CategoryCollectionBuilder() const sut = new TestContext()
.construct(); .construct();
// act // act
sut.getScriptsByLevel(level); sut.getScriptsByLevel(level);
@@ -84,93 +84,23 @@ describe('CategoryCollection', () => {
.testValidValueDoesNotThrow(RecommendationLevel.Standard); .testValidValueDoesNotThrow(RecommendationLevel.Standard);
}); });
}); });
describe('actions', () => {
it('cannot construct without actions', () => {
// arrange
const categories = [];
// act
function construct() {
new CategoryCollectionBuilder()
.withActions(categories)
.construct();
}
// assert
expect(construct).to.throw('must consist of at least one category');
});
it('cannot construct without scripts', () => {
// arrange
const categories = [
new CategoryStub(3),
new CategoryStub(2),
];
// act
function construct() {
new CategoryCollectionBuilder()
.withActions(categories)
.construct();
}
// assert
expect(construct).to.throw('must consist of at least one script');
});
describe('cannot construct without any recommended scripts', () => {
describe('single missing', () => {
// arrange
const recommendationLevels = getEnumValues(RecommendationLevel);
for (const missingLevel of recommendationLevels) {
it(`when "${RecommendationLevel[missingLevel]}" is missing`, () => {
const expectedError = `none of the scripts are recommended as "${RecommendationLevel[missingLevel]}".`;
const otherLevels = recommendationLevels.filter((level) => level !== missingLevel);
const categories = otherLevels.map(
(level, index) => new CategoryStub(index)
.withScript(
new ScriptStub(`Script${index}`).withLevel(level),
),
);
// act
const construct = () => new CategoryCollectionBuilder()
.withActions(categories)
.construct();
// assert
expect(construct).to.throw(expectedError);
});
}
});
it('multiple are missing', () => {
// arrange
const expectedError = 'none of the scripts are recommended as '
+ `"${RecommendationLevel[RecommendationLevel.Standard]}, "${RecommendationLevel[RecommendationLevel.Strict]}".`;
const categories = [
new CategoryStub(0)
.withScript(
new ScriptStub(`Script${0}`).withLevel(undefined),
),
];
// act
const construct = () => new CategoryCollectionBuilder()
.withActions(categories)
.construct();
// assert
expect(construct).to.throw(expectedError);
});
});
});
describe('totalScripts', () => { describe('totalScripts', () => {
it('returns total of initial scripts', () => { it('returns total of initial scripts', () => {
// arrange // arrange
const categories = [ const categories = [
new CategoryStub(1).withScripts( new CategoryStub('category-1').withScripts(
new ScriptStub('S1').withLevel(RecommendationLevel.Standard), new ScriptStub('S1').withLevel(RecommendationLevel.Standard),
), ),
new CategoryStub(2).withScripts( new CategoryStub('category-2').withScripts(
new ScriptStub('S2'), new ScriptStub('S2'),
new ScriptStub('S3').withLevel(RecommendationLevel.Strict), new ScriptStub('S3').withLevel(RecommendationLevel.Strict),
), ),
new CategoryStub(3).withCategories( new CategoryStub('category-3').withCategories(
new CategoryStub(4).withScripts(new ScriptStub('S4')), new CategoryStub('category-3-subcategory-1').withScripts(new ScriptStub('S4')),
), ),
]; ];
// act // act
const sut = new CategoryCollectionBuilder() const sut = new TestContext()
.withActions(categories) .withActions(categories)
.construct(); .construct();
// assert // assert
@@ -182,12 +112,12 @@ describe('CategoryCollection', () => {
// arrange // arrange
const expected = 4; const expected = 4;
const categories = [ const categories = [
new CategoryStub(1).withScripts(new ScriptStub('S1').withLevel(RecommendationLevel.Strict)), new CategoryStub('category-1').withScripts(new ScriptStub('S1').withLevel(RecommendationLevel.Strict)),
new CategoryStub(2).withScripts(new ScriptStub('S2'), new ScriptStub('S3')), new CategoryStub('category-2').withScripts(new ScriptStub('S2'), new ScriptStub('S3')),
new CategoryStub(3).withCategories(new CategoryStub(4).withScripts(new ScriptStub('S4'))), new CategoryStub('category-3').withCategories(new CategoryStub('category-3-subcategory-1').withScripts(new ScriptStub('S4'))),
]; ];
// act // act
const sut = new CategoryCollectionBuilder() const sut = new TestContext()
.withActions(categories) .withActions(categories)
.construct(); .construct();
// assert // assert
@@ -199,28 +129,19 @@ describe('CategoryCollection', () => {
// arrange // arrange
const expected = OperatingSystem.macOS; const expected = OperatingSystem.macOS;
// act // act
const sut = new CategoryCollectionBuilder() const sut = new TestContext()
.withOs(expected) .withOs(expected)
.construct(); .construct();
// assert // assert
expect(sut.os).to.deep.equal(expected); expect(sut.os).to.deep.equal(expected);
}); });
describe('throws when invalid', () => {
// act
const act = (os: OperatingSystem) => new CategoryCollectionBuilder()
.withOs(os)
.construct();
// assert
new EnumRangeTestRunner(act)
.testOutOfRangeThrows();
});
}); });
describe('scriptingDefinition', () => { describe('scriptingDefinition', () => {
it('sets scriptingDefinition as expected', () => { it('sets scriptingDefinition as expected', () => {
// arrange // arrange
const expected = getValidScriptingDefinition(); const expected = getValidScriptingDefinition();
// act // act
const sut = new CategoryCollectionBuilder() const sut = new TestContext()
.withScripting(expected) .withScripting(expected)
.construct(); .construct();
// assert // assert
@@ -230,25 +151,25 @@ describe('CategoryCollection', () => {
describe('getCategory', () => { describe('getCategory', () => {
it('throws if category is not found', () => { it('throws if category is not found', () => {
// arrange // arrange
const categoryId = 123; const missingCategoryId = 'missing-category-id';
const expectedError = `Missing category with ID: "${categoryId}"`; const expectedError = `Missing category with ID: "${missingCategoryId}"`;
const collection = new CategoryCollectionBuilder() const collection = new TestContext()
.withActions([new CategoryStub(456).withMandatoryScripts()]) .withActions([new CategoryStub(`different than ${missingCategoryId}`).withMandatoryScripts()])
.construct(); .construct();
// act // act
const act = () => collection.getCategory(categoryId); const act = () => collection.getCategory(missingCategoryId);
// assert // assert
expect(act).to.throw(expectedError); expect(act).to.throw(expectedError);
}); });
it('finds correct category', () => { it('finds correct category', () => {
// arrange // arrange
const categoryId = 123; const existingCategoryId = 'expected-action-category-id';
const expectedCategory = new CategoryStub(categoryId).withMandatoryScripts(); const expectedCategory = new CategoryStub(existingCategoryId).withMandatoryScripts();
const collection = new CategoryCollectionBuilder() const collection = new TestContext()
.withActions([expectedCategory]) .withActions([expectedCategory])
.construct(); .construct();
// act // act
const actualCategory = collection.getCategory(categoryId); const actualCategory = collection.getCategory(existingCategoryId);
// assert // assert
expect(actualCategory).to.equal(expectedCategory); expect(actualCategory).to.equal(expectedCategory);
}); });
@@ -257,9 +178,9 @@ describe('CategoryCollection', () => {
it('throws if script is not found', () => { it('throws if script is not found', () => {
// arrange // arrange
const scriptId = 'missingScript'; const scriptId = 'missingScript';
const expectedError = `missing script: ${scriptId}`; const expectedError = `Missing script: ${scriptId}`;
const collection = new CategoryCollectionBuilder() const collection = new TestContext()
.withActions([new CategoryStub(456).withMandatoryScripts()]) .withActions([new CategoryStub('parent-action').withMandatoryScripts()])
.construct(); .construct();
// act // act
const act = () => collection.getScript(scriptId); const act = () => collection.getScript(scriptId);
@@ -270,10 +191,10 @@ describe('CategoryCollection', () => {
// arrange // arrange
const scriptId = 'existingScript'; const scriptId = 'existingScript';
const expectedScript = new ScriptStub(scriptId); const expectedScript = new ScriptStub(scriptId);
const parentCategory = new CategoryStub(123) const parentCategory = new CategoryStub('parent-action')
.withMandatoryScripts() .withMandatoryScripts()
.withScript(expectedScript); .withScript(expectedScript);
const collection = new CategoryCollectionBuilder() const collection = new TestContext()
.withActions([parentCategory]) .withActions([parentCategory])
.construct(); .construct();
// act // act
@@ -293,11 +214,11 @@ function getValidScriptingDefinition(): IScriptingDefinition {
}; };
} }
class CategoryCollectionBuilder { class TestContext {
private os = OperatingSystem.Windows; private os = OperatingSystem.Windows;
private actions: readonly Category[] = [ private actions: readonly Category[] = [
new CategoryStub(1).withMandatoryScripts(), new CategoryStub(`[${TestContext.name}]-action-1`).withMandatoryScripts(),
]; ];
private scriptingDefinition: IScriptingDefinition = getValidScriptingDefinition(); private scriptingDefinition: IScriptingDefinition = getValidScriptingDefinition();

View File

@@ -0,0 +1,170 @@
import { describe, it, expect } from 'vitest';
import { validateCategoryCollection } from '@/domain/Collection/Validation/CompositeCategoryCollectionValidator';
import type { CategoryCollectionValidationContext, CategoryCollectionValidator } from '@/domain/Collection/Validation/CategoryCollectionValidator';
import { CategoryCollectionValidationContextStub } from '@tests/unit/shared/Stubs/CategoryCollectionValidationContextStub';
describe('validateCategoryCollection', () => {
it('throws error when no validators are provided', () => {
// arrange
const emptyValidators: CategoryCollectionValidator[] = [];
const expectedErrorMessage = 'No validators provided.';
// act
const act = () => new TestContext()
.withValidators(emptyValidators)
.runValidation();
// assert
expect(act).to.throw(expectedErrorMessage);
});
describe('validator execution', () => {
it('executes single validator', () => {
// arrange
let isCalled = false;
const singleValidator: CategoryCollectionValidator = () => {
isCalled = true;
};
const validators = [singleValidator];
// act
new TestContext()
.withValidators(validators)
.runValidation();
// assert
expect(isCalled).to.equal(true);
});
it('executes multiple validators in order', () => {
// arrange
const expectedExecutionSequence: readonly string[] = [
'validator1Call',
'validator2Call',
];
const actualExecutionSequence: string[] = [];
const validator1: CategoryCollectionValidator = () => {
actualExecutionSequence.push(expectedExecutionSequence[0]);
};
const validator2: CategoryCollectionValidator = () => {
actualExecutionSequence.push(expectedExecutionSequence[1]);
};
const validators = [validator1, validator2];
// act
new TestContext()
.withValidators(validators)
.runValidation();
// assert
expect(actualExecutionSequence).to.deep.equal(expectedExecutionSequence);
});
it('passes correct context to single validator', () => {
// arrange
const expectedContext = new CategoryCollectionValidationContextStub();
let actualContext: CategoryCollectionValidationContext | undefined;
const validator: CategoryCollectionValidator = (context) => {
actualContext = context;
};
const validators = [validator];
// act
new TestContext()
.withValidators(validators)
.withValidationContext(expectedContext)
.runValidation();
// assert
expect(expectedContext).to.equal(actualContext);
});
it('passes same context to all validators', () => {
// arrange
const expectedContext = new CategoryCollectionValidationContextStub();
const receivedContexts = new Array<CategoryCollectionValidationContext>();
const contextStoringValidator: CategoryCollectionValidator = (context) => {
receivedContexts.push(context);
};
const validators = [
contextStoringValidator,
contextStoringValidator,
contextStoringValidator,
];
// act
new TestContext()
.withValidators(validators)
.withValidationContext(expectedContext)
.runValidation();
// assert
expect(receivedContexts.every((c) => c === expectedContext)).to.equal(true);
});
});
it('propagates error from validator', () => {
// arrange
const expectedError = 'Error from validator';
const errorThrowingValidator: CategoryCollectionValidator = () => {
throw new Error(expectedError);
};
const validators = [errorThrowingValidator];
// act
const act = () => new TestContext()
.withValidators(validators)
.runValidation();
// Act & Assert
expect(act).to.throw(expectedError);
});
it('halts execution on validator error', () => {
// arrange
const errorThrowingValidator: CategoryCollectionValidator = () => {
throw new Error('Error from validator');
};
let isSecondValidatorCalled = false;
const secondValidator: CategoryCollectionValidator = () => {
isSecondValidatorCalled = true;
};
const validators = [errorThrowingValidator, secondValidator];
// act
try {
new TestContext()
.withValidators(validators)
.runValidation();
} catch { /* Swallow */ }
// Act & Assert
expect(isSecondValidatorCalled).to.equal(false);
});
});
class TestContext {
private validators: readonly CategoryCollectionValidator[] = [
() => {},
];
private validationContext
: CategoryCollectionValidationContext = new CategoryCollectionValidationContextStub();
public withValidators(validators: readonly CategoryCollectionValidator[]): this {
this.validators = validators;
return this;
}
public withValidationContext(validationContext: CategoryCollectionValidationContext): this {
this.validationContext = validationContext;
return this;
}
public runValidation(): ReturnType<typeof validateCategoryCollection> {
return validateCategoryCollection(
this.validationContext,
this.validators,
);
}
}

View File

@@ -0,0 +1,21 @@
import { describe } from 'vitest';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { EnumRangeTestRunner } from '@tests/unit/application/Common/EnumRangeTestRunner';
import { ensureKnownOperatingSystem } from '@/domain/Collection/Validation/Rules/EnsureKnownOperatingSystem';
import { CategoryCollectionValidationContextStub } from '@tests/unit/shared/Stubs/CategoryCollectionValidationContextStub';
describe('ensureKnownOperatingSystem', () => {
// act
const act = (os: OperatingSystem) => test(os);
// assert
new EnumRangeTestRunner(act)
.testValidValueDoesNotThrow(OperatingSystem.Android)
.testOutOfRangeThrows();
});
function test(operatingSystem: OperatingSystem):
ReturnType<typeof ensureKnownOperatingSystem> {
const context = new CategoryCollectionValidationContextStub()
.withOperatingSystem(operatingSystem);
return ensureKnownOperatingSystem(context);
}

View File

@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest';
import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
import type { Script } from '@/domain/Executables/Script/Script';
import { ensurePresenceOfAllRecommendationLevels } from '@/domain/Collection/Validation/Rules/EnsurePresenceOfAllRecommendationLevels';
import { CategoryCollectionValidationContextStub } from '@tests/unit/shared/Stubs/CategoryCollectionValidationContextStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { getEnumValues } from '@/application/Common/Enum';
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
describe('ensurePresenceOfAllRecommendationLevels', () => {
it('passes when all recommendation levels are present', () => {
// arrange
const scripts = getAllPossibleRecommendationLevels().map((level, index) => {
return new ScriptStub(`script-${index}`)
.withLevel(level);
});
// act
const act = () => test(scripts);
// assert
expect(act).to.not.throw();
});
describe('missing single level', () => {
// arrange
const recommendationLevels = getAllPossibleRecommendationLevels();
recommendationLevels.forEach((missingLevel) => {
const expectedDisplayName = getDisplayName(missingLevel);
it(`throws an error when when "${expectedDisplayName}" is missing`, () => {
const expectedError = `Missing recommendation levels: ${expectedDisplayName}.`;
const otherLevels = recommendationLevels.filter((level) => level !== missingLevel);
const scripts = otherLevels.map(
(level, index) => new ScriptStub(`script-${index}`).withLevel(level),
);
// act
const act = () => test(scripts);
// assert
expect(act).to.throw(expectedError);
});
});
});
it('throws an error with multiple missing recommendation levels', () => {
// arrange
const [
notExpectedLevelInError,
...expectedLevelsInError
] = getAllPossibleRecommendationLevels();
const scripts: Script[] = [
new ScriptStub('recommended').withLevel(notExpectedLevelInError),
];
// act
const act = () => test(scripts);
// assert
const actualErrorMessage = collectExceptionMessage(act);
expectedLevelsInError.forEach((level) => {
const expectedLevelInError = getDisplayName(level);
expect(actualErrorMessage).to.include(expectedLevelInError);
});
expect(actualErrorMessage).to.not.include(getDisplayName(notExpectedLevelInError));
});
it('throws an error when no scripts are provided', () => {
// arrange
const expectedLevelsInError = getAllPossibleRecommendationLevels()
.map((level) => getDisplayName(level));
const scripts: Script[] = [];
// act
const act = () => test(scripts);
// assert
const actualErrorMessage = collectExceptionMessage(act);
expectedLevelsInError.forEach((expectedLevelInError) => {
expect(actualErrorMessage).to.include(expectedLevelInError);
});
});
});
function test(allScripts: Script[]):
ReturnType<typeof ensurePresenceOfAllRecommendationLevels> {
const context = new CategoryCollectionValidationContextStub()
.withAllScripts(allScripts);
return ensurePresenceOfAllRecommendationLevels(context);
}
function getAllPossibleRecommendationLevels(): readonly (RecommendationLevel | undefined)[] {
return [
...getEnumValues(RecommendationLevel),
undefined,
];
}
function getDisplayName(level: RecommendationLevel | undefined): string {
return level === undefined ? 'None' : RecommendationLevel[level];
}

View File

@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { CategoryCollectionValidationContextStub } from '@tests/unit/shared/Stubs/CategoryCollectionValidationContextStub';
import type { Category } from '@/domain/Executables/Category/Category';
import { ensurePresenceOfAtLeastOneCategory } from '@/domain/Collection/Validation/Rules/EnsurePresenceOfAtLeastOneCategory';
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
describe('ensurePresenceOfAtLeastOneCategory', () => {
it('throws an error when no categories are present', () => {
// arrange
const expectedErrorMessage = 'Collection must have at least one category';
const categories: Category[] = [];
// act
const act = () => test(categories);
// assert
expect(act).to.throw(expectedErrorMessage);
});
it('does not throw an error when at least one category is present', () => {
// arrange
const categories: Category[] = [
new CategoryStub('existing-category'),
];
// act
const act = () => test(categories);
// assert
expect(act).not.to.throw();
});
});
function test(allCategories: readonly Category[]):
ReturnType<typeof ensurePresenceOfAtLeastOneCategory> {
const context = new CategoryCollectionValidationContextStub()
.withAllCategories(allCategories);
return ensurePresenceOfAtLeastOneCategory(context);
}

View File

@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { CategoryCollectionValidationContextStub } from '@tests/unit/shared/Stubs/CategoryCollectionValidationContextStub';
import type { Script } from '@/domain/Executables/Script/Script';
import { ensurePresenceOfAtLeastOneScript } from '@/domain/Collection/Validation/Rules/EnsurePresenceOfAtLeastOneScript';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
describe('ensurePresenceOfAtLeastOneScript', () => {
it('throws an error when no scripts are present', () => {
// arrange
const expectedErrorMessage = 'Collection must have at least one script';
const scripts: Script[] = [];
// act
const act = () => test(scripts);
// assert
expect(act).to.throw(expectedErrorMessage);
});
it('does not throw an error when at least one category is present', () => {
// arrange
const scripts: Script[] = [
new ScriptStub('existing-script'),
];
// act
const act = () => test(scripts);
// assert
expect(act).not.to.throw();
});
});
function test(allScripts: readonly Script[]):
ReturnType<typeof ensurePresenceOfAtLeastOneScript> {
const context = new CategoryCollectionValidationContextStub()
.withAllScripts(allScripts);
return ensurePresenceOfAtLeastOneScript(context);
}

View File

@@ -0,0 +1,146 @@
import { describe, it, expect } from 'vitest';
import { ensureUniqueIdsAcrossExecutables } from '@/domain/Collection/Validation/Rules/EnsureUniqueIdsAcrossExecutables';
import type { Category } from '@/domain/Executables/Category/Category';
import type { Script } from '@/domain/Executables/Script/Script';
import { CategoryCollectionValidationContextStub } from '@tests/unit/shared/Stubs/CategoryCollectionValidationContextStub';
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
describe('ensureUniqueIdsAcrossExecutables', () => {
it('does not throw an error when all IDs are unique', () => {
// arrange
const testData: TestData = {
categories: [
new CategoryStub('category1'),
new CategoryStub('category2'),
],
scripts: [
new ScriptStub('script1'),
new ScriptStub('script2'),
],
};
// act
const act = () => test(testData);
// assert
expect(act).to.not.throw();
});
it('throws an error when duplicate IDs are found across categories and scripts', () => {
// arrange
const duplicateId: ExecutableId = 'duplicate';
const expectedErrorMessage = `Duplicate executable IDs found: "${duplicateId}"`;
const testData: TestData = {
categories: [
new CategoryStub(duplicateId),
new CategoryStub('category2'),
],
scripts: [
new ScriptStub(duplicateId),
new ScriptStub('script2'),
],
};
// act
const act = () => test(testData);
// assert
expect(act).to.throw(expectedErrorMessage);
});
it('throws an error when duplicate IDs are found within categories', () => {
// arrange
const duplicateId: ExecutableId = 'duplicate';
const expectedErrorMessage = `Duplicate executable IDs found: "${duplicateId}"`;
const testData: TestData = {
categories: [
new CategoryStub(duplicateId),
new CategoryStub(duplicateId),
],
scripts: [
new ScriptStub('script1'),
new ScriptStub('script2'),
],
};
// act
const act = () => test(testData);
// assert
expect(act).to.throw(expectedErrorMessage);
});
it('throws an error when duplicate IDs are found within scripts', () => {
// arrange
const duplicateId: ExecutableId = 'duplicate';
const expectedErrorMessage = `Duplicate executable IDs found: "${duplicateId}"`;
const testData: TestData = {
categories: [
new CategoryStub('category1'),
new CategoryStub('category2'),
],
scripts: [
new ScriptStub(duplicateId),
new ScriptStub(duplicateId),
],
};
// act
const act = () => test(testData);
// assert
expect(act).to.throw(expectedErrorMessage);
});
it('throws an error with multiple duplicate IDs', () => {
// arrange
const duplicateId1: ExecutableId = 'duplicate-1';
const duplicateId2: ExecutableId = 'duplicate-2';
const expectedErrorMessage = `Duplicate executable IDs found: "${duplicateId1}", "${duplicateId2}"`;
const testData: TestData = {
categories: [
new CategoryStub(duplicateId1),
new CategoryStub(duplicateId2),
],
scripts: [
new ScriptStub(duplicateId1),
new ScriptStub(duplicateId2),
],
};
// act
const act = () => test(testData);
// assert
expect(act).to.throw(expectedErrorMessage);
});
it('handles empty categories and scripts arrays', () => {
// arrange
const testData: TestData = {
categories: [],
scripts: [],
};
// act
const act = () => test(testData);
// assert
expect(act).to.not.throw();
});
});
interface TestData {
readonly categories: readonly Category[];
readonly scripts: readonly Script[];
}
function test(testData: TestData):
ReturnType<typeof ensureUniqueIdsAcrossExecutables> {
const context = new CategoryCollectionValidationContextStub()
.withAllCategories(testData.categories)
.withAllScripts(testData.scripts);
return ensureUniqueIdsAcrossExecutables(context);
}

View File

@@ -0,0 +1,316 @@
import { describe, it, expect } from 'vitest';
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
import type { Category } from '@/domain/Executables/Category/Category';
import type { Script } from '@/domain/Executables/Script/Script';
import { createCategory } from '@/domain/Executables/Category/CategoryFactory';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
describe('CategoryFactory', () => {
describe('createCategory', () => {
describe('id', () => {
it('assigns id correctly', () => {
// arrange
const expectedId: ExecutableId = 'expected category id';
// act
const category = new TestContext()
.withExecutableId(expectedId)
.build();
// assert
const actualId = category.executableId;
expect(actualId).to.equal(expectedId);
});
describe('throws error if id is absent', () => {
itEachAbsentStringValue((absentValue) => {
// arrange
const expectedError = 'missing ID';
const id = absentValue;
// act
const construct = () => new TestContext()
.withExecutableId(id)
.build();
// assert
expect(construct).to.throw(expectedError);
}, { excludeNull: true, excludeUndefined: true });
});
});
describe('name', () => {
it('assigns name correctly', () => {
// arrange
const expectedName = 'expected category name';
// act
const category = new TestContext()
.withName(expectedName)
.build();
// assert
const actualName = category.name;
expect(actualName).to.equal(expectedName);
});
describe('throws error if name is absent', () => {
itEachAbsentStringValue((absentValue) => {
// arrange
const expectedError = 'missing name';
const name = absentValue;
// act
const construct = () => new TestContext()
.withName(name)
.build();
// assert
expect(construct).to.throw(expectedError);
}, { excludeNull: true, excludeUndefined: true });
});
});
describe('docs', () => {
it('assigns docs correctly', () => {
// arrange
const expectedDocs = ['expected', 'docs'];
// act
const category = new TestContext()
.withDocs(expectedDocs)
.build();
// assert
const actualDocs = category.docs;
expect(actualDocs).to.equal(expectedDocs);
});
});
describe('children', () => {
it('assigns scripts correctly', () => {
// arrange
const expectedScripts = [
new ScriptStub('expected-script-1'),
new ScriptStub('expected-script-2'),
];
// act
const category = new TestContext()
.withScripts(expectedScripts)
.build();
// assert
const actualScripts = category.scripts;
expect(actualScripts).to.equal(expectedScripts);
});
it('assigns categories correctly', () => {
// arrange
const expectedCategories = [
new CategoryStub('expected-subcategory-1'),
new CategoryStub('expected-subcategory-2'),
];
// act
const category = new TestContext()
.withSubcategories(expectedCategories)
.build();
// assert
const actualCategories = category.subcategories;
expect(actualCategories).to.equal(expectedCategories);
});
it('throws error if no children are present', () => {
// arrange
const expectedError = 'A category must have at least one sub-category or script';
const scriptChildren: readonly Script[] = [];
const categoryChildren: readonly Category[] = [];
// act
const construct = () => new TestContext()
.withSubcategories(categoryChildren)
.withScripts(scriptChildren)
.build();
// assert
expect(construct).to.throw(expectedError);
});
});
describe('getAllScriptsRecursively', () => {
it('retrieves direct child scripts', () => {
// arrange
const expectedScripts: readonly Script[] = [
new ScriptStub('expected-script-1'),
new ScriptStub('expected-script-2'),
];
const category = new TestContext()
.withScripts(expectedScripts)
.build();
// act
const actual = category.getAllScriptsRecursively();
// assert
expect(actual).to.have.deep.members(expectedScripts);
});
it('retrieves scripts from direct child categories', () => {
// arrange
const expectedScriptIds: readonly string[] = [
'1', '2', '3', '4',
];
const subcategories: readonly Category[] = [
new CategoryStub('subcategory-1').withScriptIds('1', '2'),
new CategoryStub('subcategory-2').withScriptIds('3', '4'),
];
const category = new TestContext()
.withScripts([])
.withSubcategories(subcategories)
.build();
// act
const actualIds = category
.getAllScriptsRecursively()
.map((s) => s.executableId);
// assert
expect(actualIds).to.have.deep.members(expectedScriptIds);
});
it('retrieves scripts from both direct children and child categories', () => {
// arrange
const expectedScriptIds: readonly string[] = [
'1', '2', '3', '4', '5', '6',
];
const subcategories: readonly Category[] = [
new CategoryStub('subcategory-1').withScriptIds('1', '2'),
new CategoryStub('subcategory-2').withScriptIds('3', '4'),
];
const scripts: readonly Script[] = [
new ScriptStub('5'),
new ScriptStub('6'),
];
const category = new TestContext()
.withSubcategories(subcategories)
.withScripts(scripts)
.build();
// act
const actualIds = category
.getAllScriptsRecursively()
.map((s) => s.executableId);
// assert
expect(actualIds).to.have.deep.members(expectedScriptIds);
});
it('retrieves scripts from nested categories recursively', () => {
// arrange
const expectedScriptIds: readonly string[] = [
'1', '2', '3', '4', '5', '6',
];
const subcategories: readonly Category[] = [
new CategoryStub('subcategory-1')
.withScriptIds('1', '2')
.withCategory(
new CategoryStub('subcategory-1-subcategory-1')
.withScriptIds('3', '4'),
),
new CategoryStub('subcategory-2')
.withCategories(
new CategoryStub('subcategory-2-subcategory-1')
.withScriptIds('5')
.withCategory(
new CategoryStub('subcategory-2-subcategory-1-subcategory-1')
.withCategory(
new CategoryStub('subcategory-2-subcategory-1-subcategory-1-subcategory-1')
.withScriptIds('6'),
),
),
),
];
// assert
const category = new TestContext()
.withScripts([])
.withSubcategories(subcategories)
.build();
// act
const actualIds = category
.getAllScriptsRecursively()
.map((s) => s.executableId);
// assert
expect(actualIds).to.have.deep.members(expectedScriptIds);
});
});
describe('includes', () => {
it('returns false for scripts not included', () => {
// assert
const expectedResult = false;
const script = new ScriptStub('3');
const childCategory = new CategoryStub('subcategory')
.withScriptIds('1', '2');
const category = new TestContext()
.withSubcategories([childCategory])
.build();
// act
const actual = category.includes(script);
// assert
expect(actual).to.equal(expectedResult);
});
it('returns true for scripts directly included', () => {
// assert
const expectedResult = true;
const script = new ScriptStub('3');
const childCategory = new CategoryStub('subcategory')
.withScript(script)
.withScriptIds('non-related');
const category = new TestContext()
.withSubcategories([childCategory])
.build();
// act
const actual = category.includes(script);
// assert
expect(actual).to.equal(expectedResult);
});
it('returns true for scripts included in nested categories', () => {
// assert
const expectedResult = true;
const script = new ScriptStub('3');
const childCategory = new CategoryStub('subcategory')
.withScriptIds('non-related')
.withCategory(
new CategoryStub('nested-subcategory')
.withScript(script),
);
const category = new TestContext()
.withSubcategories([childCategory])
.build();
// act
const actual = category.includes(script);
// assert
expect(actual).to.equal(expectedResult);
});
});
});
});
class TestContext {
private executableId: ExecutableId = `[${TestContext.name}] test category`;
private name = 'test-category';
private docs: ReadonlyArray<string> = [];
private subcategories: ReadonlyArray<Category> = [];
private scripts: ReadonlyArray<Script> = [
new ScriptStub(`[${TestContext.name}] script`),
];
public withExecutableId(executableId: ExecutableId): this {
this.executableId = executableId;
return this;
}
public withName(name: string): this {
this.name = name;
return this;
}
public withDocs(docs: ReadonlyArray<string>): this {
this.docs = docs;
return this;
}
public withScripts(scripts: ReadonlyArray<Script>): this {
this.scripts = scripts;
return this;
}
public withSubcategories(subcategories: ReadonlyArray<Category>): this {
this.subcategories = subcategories;
return this;
}
public build(): ReturnType<typeof createCategory> {
return createCategory({
executableId: this.executableId,
name: this.name,
docs: this.docs,
subcategories: this.subcategories,
scripts: this.scripts,
});
}
}

View File

@@ -1,217 +0,0 @@
import { describe, it, expect } from 'vitest';
import { CollectionCategory } from '@/domain/Executables/Category/CollectionCategory';
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
import type { Category } from '@/domain/Executables/Category/Category';
import type { Script } from '@/domain/Executables/Script/Script';
describe('CollectionCategory', () => {
describe('ctor', () => {
describe('throws error if name is absent', () => {
itEachAbsentStringValue((absentValue) => {
// arrange
const expectedError = 'missing name';
const name = absentValue;
// act
const construct = () => new CategoryBuilder()
.withName(name)
.build();
// assert
expect(construct).to.throw(expectedError);
}, { excludeNull: true, excludeUndefined: true });
});
it('throws error if no children are present', () => {
// arrange
const expectedError = 'A category must have at least one sub-category or script';
const scriptChildren: readonly Script[] = [];
const categoryChildren: readonly Category[] = [];
// act
const construct = () => new CategoryBuilder()
.withSubcategories(categoryChildren)
.withScripts(scriptChildren)
.build();
// assert
expect(construct).to.throw(expectedError);
});
});
describe('getAllScriptsRecursively', () => {
it('retrieves direct child scripts', () => {
// arrange
const expectedScripts = [new ScriptStub('1'), new ScriptStub('2')];
const sut = new CategoryBuilder()
.withScripts(expectedScripts)
.build();
// act
const actual = sut.getAllScriptsRecursively();
// assert
expect(actual).to.have.deep.members(expectedScripts);
});
it('retrieves scripts from direct child categories', () => {
// arrange
const expectedScriptIds = ['1', '2', '3', '4'];
const categories = [
new CategoryStub(31).withScriptIds('1', '2'),
new CategoryStub(32).withScriptIds('3', '4'),
];
const sut = new CategoryBuilder()
.withScripts([])
.withSubcategories(categories)
.build();
// act
const actualIds = sut
.getAllScriptsRecursively()
.map((s) => s.id);
// assert
expect(actualIds).to.have.deep.members(expectedScriptIds);
});
it('retrieves scripts from both direct children and child categories', () => {
// arrange
const expectedScriptIds = ['1', '2', '3', '4', '5', '6'];
const categories = [
new CategoryStub(31).withScriptIds('1', '2'),
new CategoryStub(32).withScriptIds('3', '4'),
];
const scripts = [new ScriptStub('5'), new ScriptStub('6')];
const sut = new CategoryBuilder()
.withSubcategories(categories)
.withScripts(scripts)
.build();
// act
const actualIds = sut
.getAllScriptsRecursively()
.map((s) => s.id);
// assert
expect(actualIds).to.have.deep.members(expectedScriptIds);
});
it('retrieves scripts from nested categories recursively', () => {
// arrange
const expectedScriptIds = ['1', '2', '3', '4', '5', '6'];
const categories = [
new CategoryStub(31)
.withScriptIds('1', '2')
.withCategory(
new CategoryStub(32)
.withScriptIds('3', '4'),
),
new CategoryStub(33)
.withCategories(
new CategoryStub(34)
.withScriptIds('5')
.withCategory(
new CategoryStub(35)
.withCategory(
new CategoryStub(35).withScriptIds('6'),
),
),
),
];
// assert
const sut = new CategoryBuilder()
.withScripts([])
.withSubcategories(categories)
.build();
// act
const actualIds = sut
.getAllScriptsRecursively()
.map((s) => s.id);
// assert
expect(actualIds).to.have.deep.members(expectedScriptIds);
});
});
describe('includes', () => {
it('returns false for scripts not included', () => {
// assert
const expectedResult = false;
const script = new ScriptStub('3');
const childCategory = new CategoryStub(33)
.withScriptIds('1', '2');
const sut = new CategoryBuilder()
.withSubcategories([childCategory])
.build();
// act
const actual = sut.includes(script);
// assert
expect(actual).to.equal(expectedResult);
});
it('returns true for scripts directly included', () => {
// assert
const expectedResult = true;
const script = new ScriptStub('3');
const childCategory = new CategoryStub(33)
.withScript(script)
.withScriptIds('non-related');
const sut = new CategoryBuilder()
.withSubcategories([childCategory])
.build();
// act
const actual = sut.includes(script);
// assert
expect(actual).to.equal(expectedResult);
});
it('returns true for scripts included in nested categories', () => {
// assert
const expectedResult = true;
const script = new ScriptStub('3');
const childCategory = new CategoryStub(22)
.withScriptIds('non-related')
.withCategory(new CategoryStub(33).withScript(script));
const sut = new CategoryBuilder()
.withSubcategories([childCategory])
.build();
// act
const actual = sut.includes(script);
// assert
expect(actual).to.equal(expectedResult);
});
});
});
class CategoryBuilder {
private id = 3264;
private name = 'test-script';
private docs: ReadonlyArray<string> = [];
private subcategories: ReadonlyArray<Category> = [];
private scripts: ReadonlyArray<Script> = [
new ScriptStub(`[${CategoryBuilder.name}] script`),
];
public withId(id: number): this {
this.id = id;
return this;
}
public withName(name: string): this {
this.name = name;
return this;
}
public withDocs(docs: ReadonlyArray<string>): this {
this.docs = docs;
return this;
}
public withScripts(scripts: ReadonlyArray<Script>): this {
this.scripts = scripts;
return this;
}
public withSubcategories(subcategories: ReadonlyArray<Category>): this {
this.subcategories = subcategories;
return this;
}
public build(): CollectionCategory {
return new CollectionCategory({
id: this.id,
name: this.name,
docs: this.docs,
subcategories: this.subcategories,
scripts: this.scripts,
});
}
}

View File

@@ -1,21 +1,35 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { getEnumValues } from '@/application/Common/Enum'; import { getEnumValues } from '@/application/Common/Enum';
import { CollectionScript } from '@/domain/Executables/Script/CollectionScript';
import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel'; import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode'; import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode';
import { ScriptCodeStub } from '@tests/unit/shared/Stubs/ScriptCodeStub'; import { ScriptCodeStub } from '@tests/unit/shared/Stubs/ScriptCodeStub';
import { createScript } from '@/domain/Executables/Script/ScriptFactory';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
describe('CollectionScript', () => { describe('ScriptFactory', () => {
describe('ctor', () => { describe('createScript', () => {
describe('id', () => {
it('correctly assigns id', () => {
// arrange
const expectedId: ExecutableId = 'expected-id';
// act
const script = new TestContext()
.withId(expectedId)
.build();
// assert
const actualId = script.executableId;
expect(actualId).to.equal(expectedId);
});
});
describe('scriptCode', () => { describe('scriptCode', () => {
it('assigns code correctly', () => { it('assigns code correctly', () => {
// arrange // arrange
const expected = new ScriptCodeStub(); const expected = new ScriptCodeStub();
const sut = new ScriptBuilder() const script = new TestContext()
.withCode(expected) .withCode(expected)
.build(); .build();
// act // act
const actual = sut.code; const actual = script.code;
// assert // assert
expect(actual).to.deep.equal(expected); expect(actual).to.deep.equal(expected);
}); });
@@ -23,21 +37,21 @@ describe('CollectionScript', () => {
describe('canRevert', () => { describe('canRevert', () => {
it('returns false without revert code', () => { it('returns false without revert code', () => {
// arrange // arrange
const sut = new ScriptBuilder() const script = new TestContext()
.withCodes('code') .withCodes('code')
.build(); .build();
// act // act
const actual = sut.canRevert(); const actual = script.canRevert();
// assert // assert
expect(actual).to.equal(false); expect(actual).to.equal(false);
}); });
it('returns true with revert code', () => { it('returns true with revert code', () => {
// arrange // arrange
const sut = new ScriptBuilder() const script = new TestContext()
.withCodes('code', 'non empty revert code') .withCodes('code', 'non empty revert code')
.build(); .build();
// act // act
const actual = sut.canRevert(); const actual = script.canRevert();
// assert // assert
expect(actual).to.equal(true); expect(actual).to.equal(true);
}); });
@@ -48,7 +62,7 @@ describe('CollectionScript', () => {
const invalidValue: RecommendationLevel = 55 as never; const invalidValue: RecommendationLevel = 55 as never;
const expectedError = 'invalid level'; const expectedError = 'invalid level';
// act // act
const construct = () => new ScriptBuilder() const construct = () => new TestContext()
.withRecommendationLevel(invalidValue) .withRecommendationLevel(invalidValue)
.build(); .build();
// assert // assert
@@ -58,43 +72,46 @@ describe('CollectionScript', () => {
// arrange // arrange
const expected = undefined; const expected = undefined;
// act // act
const sut = new ScriptBuilder() const script = new TestContext()
.withRecommendationLevel(expected) .withRecommendationLevel(expected)
.build(); .build();
// assert // assert
expect(sut.level).to.equal(expected); expect(script.level).to.equal(expected);
}); });
it('correctly assigns valid recommendation levels', () => { it('correctly assigns valid recommendation levels', () => {
getEnumValues(RecommendationLevel).forEach((enumValue) => {
// arrange // arrange
for (const expected of getEnumValues(RecommendationLevel)) { const expectedRecommendationLevel = enumValue;
// act // act
const sut = new ScriptBuilder() const script = new TestContext()
.withRecommendationLevel(expected) .withRecommendationLevel(expectedRecommendationLevel)
.build(); .build();
// assert // assert
const actual = sut.level; const actualRecommendationLevel = script.level;
expect(actual).to.equal(expected); expect(actualRecommendationLevel).to.equal(expectedRecommendationLevel);
} });
}); });
}); });
describe('docs', () => { describe('docs', () => {
it('correctly assigns docs', () => { it('correctly assigns docs', () => {
// arrange // arrange
const expected = ['doc1', 'doc2']; const expectedDocs = ['doc1', 'doc2'];
// act // act
const sut = new ScriptBuilder() const script = new TestContext()
.withDocs(expected) .withDocs(expectedDocs)
.build(); .build();
const actual = sut.docs;
// assert // assert
expect(actual).to.equal(expected); const actualDocs = script.docs;
expect(actualDocs).to.equal(expectedDocs);
}); });
}); });
}); });
}); });
class ScriptBuilder { class TestContext {
private name = 'test-script'; private name = `[${TestContext.name}]test-script`;
private id: ExecutableId = `[${TestContext.name}]id`;
private code: ScriptCode = new ScriptCodeStub(); private code: ScriptCode = new ScriptCodeStub();
@@ -109,6 +126,11 @@ class ScriptBuilder {
return this; return this;
} }
public withId(id: ExecutableId): this {
this.id = id;
return this;
}
public withCode(code: ScriptCode): this { public withCode(code: ScriptCode): this {
this.code = code; this.code = code;
return this; return this;
@@ -129,8 +151,9 @@ class ScriptBuilder {
return this; return this;
} }
public build(): CollectionScript { public build(): ReturnType<typeof createScript> {
return new CollectionScript({ return createScript({
executableId: this.id,
name: this.name, name: this.name,
code: this.code, code: this.code,
docs: this.docs, docs: this.docs,

View File

@@ -1,125 +1,180 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { NumericEntityStub } from '@tests/unit/shared/Stubs/NumericEntityStub';
import { InMemoryRepository } from '@/infrastructure/Repository/InMemoryRepository'; import { InMemoryRepository } from '@/infrastructure/Repository/InMemoryRepository';
import { RepositoryEntityStub } from '@tests/unit/shared/Stubs/RepositoryEntityStub';
import type { RepositoryEntity, RepositoryEntityId } from '@/application/Repository/RepositoryEntity';
describe('InMemoryRepository', () => { describe('InMemoryRepository', () => {
describe('exists', () => { describe('exists', () => {
const sut = new InMemoryRepository<number, NumericEntityStub>( it('returns true when item exists', () => {
[new NumericEntityStub(1), new NumericEntityStub(2), new NumericEntityStub(3)],
);
describe('item exists', () => {
const actual = sut.exists(1);
it('returns true', () => expect(actual).to.be.true);
});
describe('item does not exist', () => {
const actual = sut.exists(99);
it('returns false', () => expect(actual).to.be.false);
});
});
it('getItems gets initial items', () => {
// arrange // arrange
const expected = [ const expectedExistence = true;
new NumericEntityStub(1), new NumericEntityStub(2), const existingItemId: RepositoryEntityId = 'existing-entity-id';
new NumericEntityStub(3), new NumericEntityStub(4), const items: readonly RepositoryEntity[] = [
new RepositoryEntityStub('unrelated-entity-1'),
new RepositoryEntityStub(existingItemId),
new RepositoryEntityStub('unrelated-entity-2'),
]; ];
const sut = new InMemoryRepository(items);
// act // act
const sut = new InMemoryRepository<number, NumericEntityStub>(expected); const actualExistence = sut.exists(existingItemId);
const actual = sut.getItems();
// assert // assert
expect(actual).to.deep.equal(expected); expect(actualExistence).to.equal(expectedExistence);
});
it('returns false when item does not exist', () => {
// arrange
const expectedExistence = false;
const absentItemId: RepositoryEntityId = 'id-that-does-not-belong';
const items: readonly RepositoryEntity[] = [
new RepositoryEntityStub('unrelated-entity-1'),
new RepositoryEntityStub('unrelated-entity-2'),
];
const sut = new InMemoryRepository(items);
// act
const actualExistence = sut.exists(absentItemId);
// assert
expect(actualExistence).to.equal(expectedExistence);
});
});
describe('getItems', () => {
it('returns initial items', () => {
// arrange
const expectedItems: readonly RepositoryEntity[] = [
new RepositoryEntityStub('expected-item-1'),
new RepositoryEntityStub('expected-item-2'),
new RepositoryEntityStub('expected-item-3'),
];
// act
const sut = new InMemoryRepository(expectedItems);
const actualItems = sut.getItems();
// assert
expect(actualItems).to.have.lengthOf(expectedItems.length);
expect(actualItems).to.deep.members(expectedItems);
});
}); });
describe('addItem', () => { describe('addItem', () => {
it('adds', () => { it('increases length', () => {
// arrange // arrange
const sut = new InMemoryRepository<number, NumericEntityStub>(); const sut = new InMemoryRepository<RepositoryEntity>();
const expected = { const expectedLength = 1;
length: 1,
item: new NumericEntityStub(1),
};
// act // act
sut.addItem(expected.item); sut.addItem(new RepositoryEntityStub('unrelated-id'));
const actual = {
length: sut.length,
item: sut.getItems()[0],
};
// assert // assert
expect(actual.length).to.equal(expected.length); const actualLength = sut.length;
expect(actual.item).to.deep.equal(expected.item); expect(actualLength).to.equal(expectedLength);
}); });
}); it('adds as item', () => {
it('removeItem removes', () => {
// arrange // arrange
const initialItems = [ const sut = new InMemoryRepository<RepositoryEntity>();
new NumericEntityStub(1), new NumericEntityStub(2), const expectedItem = new RepositoryEntityStub('expected-entity-id');
new NumericEntityStub(3), new NumericEntityStub(4),
// act
sut.addItem(expectedItem);
// assert
const actualItems = sut.getItems();
expect(actualItems).to.have.lengthOf(1);
expect(actualItems).to.deep.include(expectedItem);
});
});
describe('removeItem', () => {
it('decreases length', () => {
// arrange
const itemIdToDelete: RepositoryEntityId = 'entity-id-to-be-deleted';
const initialItems: readonly RepositoryEntity[] = [
new RepositoryEntityStub('entity-to-be-retained-1'),
new RepositoryEntityStub(itemIdToDelete),
new RepositoryEntityStub('entity-to-be-retained-2'),
]; ];
const idToDelete = 3; const expectedLength = 2;
const expected = { const sut = new InMemoryRepository<RepositoryEntity>(initialItems);
length: 3,
items: [new NumericEntityStub(1), new NumericEntityStub(2), new NumericEntityStub(4)],
};
const sut = new InMemoryRepository<number, NumericEntityStub>(initialItems);
// act // act
sut.removeItem(idToDelete); sut.removeItem(itemIdToDelete);
const actual = {
length: sut.length,
items: sut.getItems(),
};
// assert // assert
expect(actual.length).to.equal(expected.length); const actualLength = sut.length;
expect(actual.items).to.deep.equal(expected.items); expect(actualLength).to.equal(expectedLength);
});
it('removes from items', () => {
// arrange
const expectedItems: readonly RepositoryEntity[] = [
new RepositoryEntityStub('entity-to-be-retained-1'),
new RepositoryEntityStub('entity-to-be-retained-2'),
];
const itemIdToDelete: RepositoryEntityId = 'entity-id-to-be-deleted';
const initialItems: readonly RepositoryEntity[] = [
...expectedItems,
new RepositoryEntityStub(itemIdToDelete),
];
const sut = new InMemoryRepository<RepositoryEntity>(initialItems);
// act
sut.removeItem(itemIdToDelete);
// assert
const actualItems = sut.getItems();
expect(actualItems).to.have.lengthOf(expectedItems.length);
expect(actualItems).to.have.deep.members(expectedItems);
});
}); });
describe('addOrUpdateItem', () => { describe('addOrUpdateItem', () => {
it('adds when item does not exist', () => { it('adds when item does not exist', () => {
// arrange // arrange
const initialItems = [new NumericEntityStub(1), new NumericEntityStub(2)]; const initialItems: readonly RepositoryEntity[] = [
const newItem = new NumericEntityStub(3); new RepositoryEntityStub('existing-item-1'),
const expected = [...initialItems, newItem]; new RepositoryEntityStub('existing-item-2'),
const sut = new InMemoryRepository<number, NumericEntityStub>(initialItems); ];
const newItem = new RepositoryEntityStub('new-item');
const expectedItems: readonly RepositoryEntity[] = [
...initialItems,
newItem,
];
const sut = new InMemoryRepository(initialItems);
// act // act
sut.addOrUpdateItem(newItem); sut.addOrUpdateItem(newItem);
// assert // assert
const actual = sut.getItems(); const actualItems = sut.getItems();
expect(actual).to.deep.equal(expected); expect(actualItems).to.have.lengthOf(expectedItems.length);
expect(actualItems).to.have.members(expectedItems);
}); });
it('updates when item exists', () => { it('updates when item exists', () => {
// arrange // arrange
const initialItems = [new NumericEntityStub(1).withCustomProperty('bca')]; const itemId: RepositoryEntityId = 'same-item-id';
const updatedItem = new NumericEntityStub(1).withCustomProperty('abc'); const initialItems: readonly RepositoryEntity[] = [
const expected = [updatedItem]; new RepositoryEntityStub(itemId)
const sut = new InMemoryRepository<number, NumericEntityStub>(initialItems); .withCustomPropertyValue('initial-property-value'),
];
const updatedItem = new RepositoryEntityStub(itemId)
.withCustomPropertyValue('changed-property-value');
const sut = new InMemoryRepository(initialItems);
// act // act
sut.addOrUpdateItem(updatedItem); sut.addOrUpdateItem(updatedItem);
// assert // assert
const actual = sut.getItems(); const actualItems = sut.getItems();
expect(actual).to.deep.equal(expected); expect(actualItems).to.have.lengthOf(1);
expect(actualItems[0]).to.equal(updatedItem);
}); });
}); });
describe('getById', () => { describe('getById', () => {
it('returns entity if it exists', () => { it('returns entity if it exists', () => {
// arrange // arrange
const expected = new NumericEntityStub(1).withCustomProperty('bca'); const existingId: RepositoryEntityId = 'existing-item-id';
const sut = new InMemoryRepository<number, NumericEntityStub>([ const expectedItem = new RepositoryEntityStub(existingId)
expected, new NumericEntityStub(2).withCustomProperty('bca'), .withCustomPropertyValue('bca');
new NumericEntityStub(3).withCustomProperty('bca'), new NumericEntityStub(4).withCustomProperty('bca'), const initialItems: readonly RepositoryEntity[] = [
]); new RepositoryEntityStub('unrelated-entity'),
expectedItem,
new RepositoryEntityStub('different-id-same-property').withCustomPropertyValue('bca'),
];
const sut = new InMemoryRepository(initialItems);
// act // act
const actual = sut.getById(expected.id); const actualItem = sut.getById(expectedItem.id);
// assert // assert
expect(actual).to.deep.equal(expected); expect(actualItem).to.deep.equal(expectedItem);
}); });
it('throws if item does not exist', () => { it('throws if item does not exist', () => {
// arrange // arrange
const id = 31; const id: RepositoryEntityId = 'id-that-does-not-exist';
const expectedError = `missing item: ${id}`; const expectedError = `missing item: ${id}`;
const sut = new InMemoryRepository<number, NumericEntityStub>([]); const sut = new InMemoryRepository<RepositoryEntityStub>();
// act // act
const act = () => sut.getById(id); const act = () => sut.getById(id);
// assert // assert

View File

@@ -145,7 +145,7 @@ describe('RecommendationStatusHandler', () => {
return `total: ${testCase.selection.length}\n` return `total: ${testCase.selection.length}\n`
+ 'scripts:\n' + 'scripts:\n'
+ testCase.selection + testCase.selection
.map((s) => `{ id: ${s.script.id}, level: ${s.script.level === undefined ? 'unknown' : RecommendationLevel[s.script.level]} }`) .map((s) => `{ id: ${s.script.executableId}, level: ${s.script.level === undefined ? 'unknown' : RecommendationLevel[s.script.level]} }`)
.join(' | '); .join(' | ');
} }
}); });

View File

@@ -3,12 +3,12 @@ import { CategoryReverter } from '@/presentation/components/Scripts/View/Tree/No
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub'; import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub'; import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub'; import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { getCategoryNodeId } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter';
import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub'; import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub';
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub'; import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript'; import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { CategorySelectionStub } from '@tests/unit/shared/Stubs/CategorySelectionStub'; import { CategorySelectionStub } from '@tests/unit/shared/Stubs/CategorySelectionStub';
import type { Script } from '@/domain/Executables/Script/Script'; import type { Script } from '@/domain/Executables/Script/Script';
import { createExecutableIdFromNodeId } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter';
describe('CategoryReverter', () => { describe('CategoryReverter', () => {
describe('getState', () => { describe('getState', () => {
@@ -122,8 +122,8 @@ describe('CategoryReverter', () => {
}) => { }) => {
it(description, () => { it(description, () => {
// arrange // arrange
const category = new CategoryStub(1).withScripts(...allScripts); const category = new CategoryStub('parent-category-id').withScripts(...allScripts);
const categoryNodeId = getCategoryNodeId(category); const categoryNodeId = createExecutableIdFromNodeId(category.executableId);
const collection = new CategoryCollectionStub().withAction(category); const collection = new CategoryCollectionStub().withAction(category);
const categoryReverter = new CategoryReverter(categoryNodeId, collection); const categoryReverter = new CategoryReverter(categoryNodeId, collection);
const selectedScripts = selectScripts(allScripts); const selectedScripts = selectScripts(allScripts);
@@ -157,8 +157,8 @@ describe('CategoryReverter', () => {
new ScriptStub('reversible').withReversibility(true), new ScriptStub('reversible').withReversibility(true),
new ScriptStub('reversible2').withReversibility(true), new ScriptStub('reversible2').withReversibility(true),
]; ];
const category = new CategoryStub(1).withScripts(...allScripts); const category = new CategoryStub('parent-category').withScripts(...allScripts);
const nodeId = getCategoryNodeId(category); const nodeId = createExecutableIdFromNodeId(category.executableId);
const collection = new CategoryCollectionStub().withAction(category); const collection = new CategoryCollectionStub().withAction(category);
const categorySelection = new CategorySelectionStub(); const categorySelection = new CategorySelectionStub();
const categoryReverter = new CategoryReverter(nodeId, collection); const categoryReverter = new CategoryReverter(nodeId, collection);
@@ -170,7 +170,7 @@ describe('CategoryReverter', () => {
); );
// assert // assert
const actualRevertState = categorySelection.isCategorySelected( const actualRevertState = categorySelection.isCategorySelected(
category.id, category.executableId,
expectedRevertState, expectedRevertState,
); );
expect(actualRevertState).to.equal(true); expect(actualRevertState).to.equal(true);

View File

@@ -5,15 +5,16 @@ import { CategoryReverter } from '@/presentation/components/Scripts/View/Tree/No
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub'; import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub'; import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub'; import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { getCategoryNodeId, getScriptNodeId } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter'; import { createNodeIdForExecutable } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter';
import { type NodeMetadata, NodeType } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata'; import { type NodeMetadata, NodeType } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata';
import type { TreeNodeId } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
describe('ReverterFactory', () => { describe('ReverterFactory', () => {
describe('getReverter', () => { describe('getReverter', () => {
it('gets CategoryReverter for category node', () => { it(`gets ${CategoryReverter.name} for category node`, () => {
// arrange // arrange
const category = new CategoryStub(0).withScriptIds('55'); const category = new CategoryStub('test-action-category').withScriptIds('55');
const node = getNodeContentStub(getCategoryNodeId(category), NodeType.Category); const node = getNodeContentStub(createNodeIdForExecutable(category), NodeType.Category);
const collection = new CategoryCollectionStub() const collection = new CategoryCollectionStub()
.withAction(category); .withAction(category);
// act // act
@@ -21,21 +22,21 @@ describe('ReverterFactory', () => {
// assert // assert
expect(result instanceof CategoryReverter).to.equal(true); expect(result instanceof CategoryReverter).to.equal(true);
}); });
it('gets ScriptReverter for script node', () => { it(`gets ${ScriptReverter.name} for script node`, () => {
// arrange // arrange
const script = new ScriptStub('test'); const script = new ScriptStub('test');
const node = getNodeContentStub(getScriptNodeId(script), NodeType.Script); const node = getNodeContentStub(createNodeIdForExecutable(script), NodeType.Script);
const collection = new CategoryCollectionStub() const collection = new CategoryCollectionStub()
.withAction(new CategoryStub(0).withScript(script)); .withAction(new CategoryStub('test-action-category').withScript(script));
// act // act
const result = getReverter(node, collection); const result = getReverter(node, collection);
// assert // assert
expect(result instanceof ScriptReverter).to.equal(true); expect(result instanceof ScriptReverter).to.equal(true);
}); });
}); });
function getNodeContentStub(nodeId: string, type: NodeType): NodeMetadata { function getNodeContentStub(nodeId: TreeNodeId, type: NodeType): NodeMetadata {
return { return {
id: nodeId, executableId: nodeId,
text: 'text', text: 'text',
isReversible: false, isReversible: false,
docs: [], docs: [],

View File

@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
import { ScriptReverter } from '@/presentation/components/Scripts/View/Tree/NodeContent/Reverter/ScriptReverter'; import { ScriptReverter } from '@/presentation/components/Scripts/View/Tree/NodeContent/Reverter/ScriptReverter';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub'; import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub'; import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
import { getScriptNodeId } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter'; import { createNodeIdForExecutable } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter';
import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub'; import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub';
import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript'; import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { ScriptSelectionStub } from '@tests/unit/shared/Stubs/ScriptSelectionStub'; import { ScriptSelectionStub } from '@tests/unit/shared/Stubs/ScriptSelectionStub';
@@ -11,7 +11,7 @@ describe('ScriptReverter', () => {
describe('getState', () => { describe('getState', () => {
// arrange // arrange
const script = new ScriptStub('id'); const script = new ScriptStub('id');
const nodeId = getScriptNodeId(script); const nodeId = createNodeIdForExecutable(script);
const testScenarios: ReadonlyArray<{ const testScenarios: ReadonlyArray<{
readonly description: string; readonly description: string;
readonly selectedScripts: readonly SelectedScript[]; readonly selectedScripts: readonly SelectedScript[];
@@ -98,7 +98,7 @@ describe('ScriptReverter', () => {
expectedRevert: false, expectedRevert: false,
}, },
]; ];
const nodeId = getScriptNodeId(script); const nodeId = createNodeIdForExecutable(script);
testScenarios.forEach(( testScenarios.forEach((
{ description, selection, expectedRevert }, { description, selection, expectedRevert },
) => { ) => {
@@ -111,7 +111,9 @@ describe('ScriptReverter', () => {
// act // act
sut.selectWithRevertState(revertState, userSelection); sut.selectWithRevertState(revertState, userSelection);
// assert // assert
expect(scriptSelection.isScriptSelected(script.id, expectedRevert)).to.equal(true); const isActuallySelected = scriptSelection
.isScriptSelected(script.executableId, expectedRevert);
expect(isActuallySelected).to.equal(true);
}); });
}); });
}); });

View File

@@ -3,13 +3,14 @@ import { TreeNodeManager } from '@/presentation/components/Scripts/View/Tree/Tre
import { itEachAbsentObjectValue, itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests'; import { itEachAbsentObjectValue, itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { TreeNodeHierarchy } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/Hierarchy/TreeNodeHierarchy'; import { TreeNodeHierarchy } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/Hierarchy/TreeNodeHierarchy';
import { TreeNodeState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/TreeNodeState'; import { TreeNodeState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/TreeNodeState';
import type { TreeNodeId } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
describe('TreeNodeManager', () => { describe('TreeNodeManager', () => {
describe('constructor', () => { describe('constructor', () => {
describe('id', () => { describe('id', () => {
it('should initialize with the provided id', () => { it('should initialize with the provided id', () => {
// arrange // arrange
const expectedId = 'test-id'; const expectedId: TreeNodeId = 'test-id';
// act // act
const node = new TreeNodeManager(expectedId); const node = new TreeNodeManager(expectedId);
// assert // assert
@@ -18,9 +19,10 @@ describe('TreeNodeManager', () => {
describe('should throw an error if id is not provided', () => { describe('should throw an error if id is not provided', () => {
itEachAbsentStringValue((absentId) => { itEachAbsentStringValue((absentId) => {
// arrange // arrange
const id = absentId as TreeNodeId;
const expectedError = 'missing id'; const expectedError = 'missing id';
// act // act
const act = () => new TreeNodeManager(absentId); const act = () => new TreeNodeManager(id);
// assert // assert
expect(act).to.throw(expectedError); expect(act).to.throw(expectedError);
}, { excludeNull: true, excludeUndefined: true }); }, { excludeNull: true, excludeUndefined: true });

View File

@@ -5,31 +5,36 @@ import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub'; import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub'; import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
import { import {
getCategoryId, getCategoryNodeId, getScriptId, createExecutableIdFromNodeId,
getScriptNodeId, parseAllCategories, parseSingleCategory, createNodeIdForExecutable,
parseAllCategories,
parseSingleCategory,
} from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter'; } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter';
import { ExecutableType } from '@/application/Parser/Executable/Validation/ExecutableType'; import { ExecutableType } from '@/application/Parser/Executable/Validation/ExecutableType';
import type { NodeMetadata } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata'; import type { NodeMetadata } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata';
import { expectExists } from '@tests/shared/Assertions/ExpectExists'; import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
describe('CategoryNodeMetadataConverter', () => { describe('CategoryNodeMetadataConverter', () => {
it('can convert script id and back', () => { it('can convert script id and back', () => {
// arrange // arrange
const script = new ScriptStub('test'); const expectedScriptId: ExecutableId = 'expected-script-id';
const script = new ScriptStub(expectedScriptId);
// act // act
const nodeId = getScriptNodeId(script); const nodeId = createNodeIdForExecutable(script);
const scriptId = getScriptId(nodeId); const actualScriptId = createExecutableIdFromNodeId(nodeId);
// assert // assert
expect(scriptId).to.equal(script.id); expect(actualScriptId).to.equal(expectedScriptId);
}); });
it('can convert category id and back', () => { it('can convert category id and back', () => {
// arrange // arrange
const category = new CategoryStub(55); const expectedCategoryId: ExecutableId = 'expected-category-id';
const category = new CategoryStub(expectedCategoryId);
// act // act
const nodeId = getCategoryNodeId(category); const nodeId = createNodeIdForExecutable(category);
const scriptId = getCategoryId(nodeId); const actualCategoryId = createExecutableIdFromNodeId(nodeId);
// assert // assert
expect(scriptId).to.equal(category.id); expect(actualCategoryId).to.equal(expectedCategoryId);
}); });
describe('parseSingleCategory', () => { describe('parseSingleCategory', () => {
it('throws error if parent category cannot be retrieved', () => { it('throws error if parent category cannot be retrieved', () => {
@@ -38,32 +43,45 @@ describe('CategoryNodeMetadataConverter', () => {
const collection = new CategoryCollectionStub(); const collection = new CategoryCollectionStub();
collection.getCategory = () => { throw new Error(expectedError); }; collection.getCategory = () => { throw new Error(expectedError); };
// act // act
const act = () => parseSingleCategory(31, collection); const act = () => parseSingleCategory('unimportant-id', collection);
// assert // assert
expect(act).to.throw(expectedError); expect(act).to.throw(expectedError);
}); });
it('can parse when category has sub categories', () => { it('can parse when category has sub categories', () => {
// arrange // arrange
const categoryId = 31; const parentCategoryId: ExecutableId = 'parent-category';
const firstSubCategory = new CategoryStub(11).withScriptIds('111', '112'); const firstSubcategory = new CategoryStub('subcategory-1')
const secondSubCategory = new CategoryStub(categoryId) .withScriptIds('subcategory-1-script-1', 'subcategory-1-script-2');
.withCategory(new CategoryStub(33).withScriptIds('331', '331')) const secondSubCategory = new CategoryStub('subcategory-2')
.withCategory(new CategoryStub(44).withScriptIds('44')); .withCategory(
const collection = new CategoryCollectionStub().withAction(new CategoryStub(categoryId) new CategoryStub('subcategory-2-subcategory-1')
.withCategory(firstSubCategory) .withScriptIds('subcategory-2-subcategory-1-script-1', 'subcategory-2-subcategory-1-script-2'),
.withCategory(secondSubCategory)); )
.withCategory(
new CategoryStub('subcategory-2-subcategory-2')
.withScriptIds('subcategory-2-subcategory-2-script-1'),
);
const collection = new CategoryCollectionStub().withAction(
new CategoryStub(parentCategoryId)
.withCategory(firstSubcategory)
.withCategory(secondSubCategory),
);
// act // act
const nodes = parseSingleCategory(categoryId, collection); const nodes = parseSingleCategory(parentCategoryId, collection);
// assert // assert
expectExists(nodes); expectExists(nodes);
expect(nodes).to.have.lengthOf(2); expect(nodes).to.have.lengthOf(2);
expectSameCategory(nodes[0], firstSubCategory); expectSameCategory(nodes[0], firstSubcategory);
expectSameCategory(nodes[1], secondSubCategory); expectSameCategory(nodes[1], secondSubCategory);
}); });
it('can parse when category has sub scripts', () => { it('can parse when category has sub scripts', () => {
// arrange // arrange
const categoryId = 31; const categoryId: ExecutableId = 'expected-category-id';
const scripts = [new ScriptStub('script1'), new ScriptStub('script2'), new ScriptStub('script3')]; const scripts: readonly Script[] = [
new ScriptStub('script1'),
new ScriptStub('script2'),
new ScriptStub('script3'),
];
const collection = new CategoryCollectionStub() const collection = new CategoryCollectionStub()
.withAction(new CategoryStub(categoryId).withScripts(...scripts)); .withAction(new CategoryStub(categoryId).withScripts(...scripts));
// act // act
@@ -79,10 +97,11 @@ describe('CategoryNodeMetadataConverter', () => {
it('parseAllCategories parses as expected', () => { it('parseAllCategories parses as expected', () => {
// arrange // arrange
const collection = new CategoryCollectionStub() const collection = new CategoryCollectionStub()
.withAction(new CategoryStub(0).withScriptIds('1, 2')) .withAction(new CategoryStub('category-1').withScriptIds('1, 2'))
.withAction(new CategoryStub(1).withCategories( .withAction(new CategoryStub('category-2').withCategories(
new CategoryStub(3).withScriptIds('3', '4'), new CategoryStub('category-2-subcategory-1').withScriptIds('3', '4'),
new CategoryStub(4).withCategory(new CategoryStub(5).withScriptIds('6')), new CategoryStub('category-2-subcategory-1')
.withCategory(new CategoryStub('category-2-subcategory-1-subcategory-1').withScriptIds('6')),
)); ));
// act // act
const nodes = parseAllCategories(collection); const nodes = parseAllCategories(collection);
@@ -100,8 +119,8 @@ function isReversible(category: Category): boolean {
return false; return false;
} }
} }
if (category.subCategories) { if (category.subcategories) {
if (category.subCategories.some((c) => !isReversible(c))) { if (category.subcategories.some((c) => !isReversible(c))) {
return false; return false;
} }
} }
@@ -110,17 +129,17 @@ function isReversible(category: Category): boolean {
function expectSameCategory(node: NodeMetadata, category: Category): void { function expectSameCategory(node: NodeMetadata, category: Category): void {
expect(node.type).to.equal(ExecutableType.Category, getErrorMessage('type')); expect(node.type).to.equal(ExecutableType.Category, getErrorMessage('type'));
expect(node.id).to.equal(getCategoryNodeId(category), getErrorMessage('id')); expect(node.executableId).to.equal(createNodeIdForExecutable(category), getErrorMessage('id'));
expect(node.docs).to.equal(category.docs, getErrorMessage('docs')); expect(node.docs).to.equal(category.docs, getErrorMessage('docs'));
expect(node.text).to.equal(category.name, getErrorMessage('name')); expect(node.text).to.equal(category.name, getErrorMessage('name'));
expect(node.isReversible).to.equal(isReversible(category), getErrorMessage('isReversible')); expect(node.isReversible).to.equal(isReversible(category), getErrorMessage('isReversible'));
expect(node.children).to.have.lengthOf( expect(node.children).to.have.lengthOf(
category.scripts.length + category.subCategories.length, category.scripts.length + category.subcategories.length,
getErrorMessage('total children'), getErrorMessage('total children'),
); );
if (category.subCategories) { if (category.subcategories) {
for (let i = 0; i < category.subCategories.length; i++) { for (let i = 0; i < category.subcategories.length; i++) {
expectSameCategory(node.children[i], category.subCategories[i]); expectSameCategory(node.children[i], category.subcategories[i]);
} }
} }
if (category.scripts) { if (category.scripts) {
@@ -137,7 +156,7 @@ function expectSameCategory(node: NodeMetadata, category: Category): void {
function expectSameScript(node: NodeMetadata, script: Script): void { function expectSameScript(node: NodeMetadata, script: Script): void {
expect(node.type).to.equal(ExecutableType.Script, getErrorMessage('type')); expect(node.type).to.equal(ExecutableType.Script, getErrorMessage('type'));
expect(node.id).to.equal(getScriptNodeId(script), getErrorMessage('id')); expect(node.executableId).to.equal(createNodeIdForExecutable(script), getErrorMessage('id'));
expect(node.docs).to.equal(script.docs, getErrorMessage('docs')); expect(node.docs).to.equal(script.docs, getErrorMessage('docs'));
expect(node.text).to.equal(script.name, getErrorMessage('name')); expect(node.text).to.equal(script.name, getErrorMessage('name'));
expect(node.isReversible).to.equal(script.canRevert(), getErrorMessage('canRevert')); expect(node.isReversible).to.equal(script.canRevert(), getErrorMessage('canRevert'));

Some files were not shown because too many files have changed in this diff Show More