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

@@ -1,5 +1,5 @@
import type { IApplication } from '@/domain/IApplication';
import type { ICategoryCollection } from '@/domain/ICategoryCollection';
import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { OperatingSystem } from '@/domain/OperatingSystem';
import type { ProjectDetails } from '@/domain/Project/ProjectDetails';
import { ProjectDetailsStub } from './ProjectDetailsStub';

View File

@@ -1,6 +1,6 @@
import type { CategoryCollectionFactory } from '@/application/Parser/CategoryCollectionParser';
import type { CategoryCollectionInitParameters } from '@/domain/CategoryCollection';
import type { ICategoryCollection } from '@/domain/ICategoryCollection';
import type { CategoryCollectionInitParameters } from '@/domain/Collection/CategoryCollection';
import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { CategoryCollectionStub } from './CategoryCollectionStub';
export function createCategoryCollectionFactorySpy(): {

View File

@@ -1,5 +1,5 @@
import type { ProjectDetails } from '@/domain/Project/ProjectDetails';
import type { ICategoryCollection } from '@/domain/ICategoryCollection';
import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { getEnumValues } from '@/application/Common/Enum';
import type { CollectionData } from '@/application/collections/';
import { OperatingSystem } from '@/domain/OperatingSystem';

View File

@@ -3,7 +3,7 @@ import type { ICategoryCollectionState } from '@/application/Context/State/ICate
import { OperatingSystem } from '@/domain/OperatingSystem';
import type { Script } from '@/domain/Executables/Script/Script';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import type { ICategoryCollection } from '@/domain/ICategoryCollection';
import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import type { UserSelection } from '@/application/Context/State/Selection/UserSelection';
import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import type { FilterContext } from '@/application/Context/State/Filter/FilterContext';
@@ -33,7 +33,10 @@ export class CategoryCollectionStateStub implements ICategoryCollectionState {
this.collection = new CategoryCollectionStub()
.withOs(this.os)
.withTotalScripts(this.allScripts.length)
.withAction(new CategoryStub(0).withScripts(...allScripts));
.withAction(
new CategoryStub(`[${CategoryCollectionStateStub.name}]-default-action`)
.withScripts(...allScripts),
);
}
public withCollection(collection: ICategoryCollection): this {

View File

@@ -2,8 +2,9 @@ import { OperatingSystem } from '@/domain/OperatingSystem';
import type { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import type { Script } from '@/domain/Executables/Script/Script';
import type { Category } from '@/domain/Executables/Category/Category';
import type { ICategoryCollection } from '@/domain/ICategoryCollection';
import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import { ScriptStub } from './ScriptStub';
import { ScriptingDefinitionStub } from './ScriptingDefinitionStub';
import { CategoryStub } from './CategoryStub';
@@ -22,9 +23,9 @@ export class CategoryCollectionStub implements ICategoryCollection {
public readonly actions = new Array<Category>();
public withSomeActions(): this {
this.withAction(new CategoryStub(1));
this.withAction(new CategoryStub(2));
this.withAction(new CategoryStub(3));
this.withAction(new CategoryStub(`[${CategoryCollectionStub}]-action-1`));
this.withAction(new CategoryStub(`[${CategoryCollectionStub}]-action-2`));
this.withAction(new CategoryStub(`[${CategoryCollectionStub}]-action-3`));
return this;
}
@@ -60,9 +61,9 @@ export class CategoryCollectionStub implements ICategoryCollection {
return this;
}
public getCategory(categoryId: number): Category {
public getCategory(categoryId: ExecutableId): Category {
return this.getAllCategories()
.find((category) => category.id === categoryId)
.find((category) => category.executableId === categoryId)
?? new CategoryStub(categoryId);
}
@@ -71,10 +72,10 @@ export class CategoryCollectionStub implements ICategoryCollection {
.filter((script) => script.level !== undefined && script.level <= level);
}
public getScript(scriptId: string): Script {
public getScript(executableId: ExecutableId): Script {
return this.getAllScripts()
.find((script) => scriptId === script.id)
?? new ScriptStub(scriptId);
.find((script) => executableId === script.executableId)
?? new ScriptStub(executableId);
}
public getAllScripts(): ReadonlyArray<Script> {
@@ -89,7 +90,7 @@ export class CategoryCollectionStub implements ICategoryCollection {
}
function getSubCategoriesRecursively(category: Category): ReadonlyArray<Category> {
return (category.subCategories || []).flatMap(
return (category.subcategories || []).flatMap(
(subCategory) => [subCategory, ...getSubCategoriesRecursively(subCategory)],
);
}
@@ -97,7 +98,7 @@ function getSubCategoriesRecursively(category: Category): ReadonlyArray<Category
function getScriptsRecursively(category: Category): ReadonlyArray<Script> {
return [
...(category.scripts || []),
...(category.subCategories || []).flatMap(
...(category.subcategories || []).flatMap(
(subCategory) => getScriptsRecursively(subCategory),
),
];

View File

@@ -0,0 +1,34 @@
import type { CategoryCollectionValidationContext } from '@/domain/Collection/Validation/CategoryCollectionValidator';
import type { Category } from '@/domain/Executables/Category/Category';
import type { Script } from '@/domain/Executables/Script/Script';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { CategoryStub } from './CategoryStub';
import { ScriptStub } from './ScriptStub';
export class CategoryCollectionValidationContextStub
implements CategoryCollectionValidationContext {
public allScripts: readonly Script[] = [
new ScriptStub(`[${CategoryCollectionValidationContextStub.name}] test-script`),
];
public allCategories: readonly Category[] = [
new CategoryStub(`[${CategoryCollectionValidationContextStub.name}] test-category`),
];
public operatingSystem: OperatingSystem = OperatingSystem.iPadOS;
public withOperatingSystem(operatingSystem: OperatingSystem): this {
this.operatingSystem = operatingSystem;
return this;
}
public withAllCategories(allCategories: readonly Category[]): this {
this.allCategories = allCategories;
return this;
}
public withAllScripts(allScripts: readonly Script[]): this {
this.allScripts = allScripts;
return this;
}
}

View File

@@ -1,6 +1,5 @@
import type { CategoryFactory } from '@/application/Parser/Executable/CategoryParser';
import type { CategoryInitParameters } from '@/domain/Executables/Category/CollectionCategory';
import type { Category } from '@/domain/Executables/Category/Category';
import type { CategoryFactory, CategoryInitParameters } from '@/domain/Executables/Category/CategoryFactory';
import { CategoryStub } from './CategoryStub';
export function createCategoryFactorySpy(): {
@@ -10,7 +9,7 @@ export function createCategoryFactorySpy(): {
const createdCategories = new Map<Category, CategoryInitParameters>();
return {
categoryFactorySpy: (parameters) => {
const category = new CategoryStub(55);
const category = new CategoryStub('category-from-factory-stub');
createdCategories.set(category, parameters);
return category;
},

View File

@@ -16,7 +16,7 @@ export class CategoryParserStub {
if (result) {
return result;
}
return new CategoryStub(5489);
return new CategoryStub(`[${CategoryParserStub.name}]-parsed-category`);
};
}

View File

@@ -1,11 +1,12 @@
import type { CategorySelection } from '@/application/Context/State/Selection/Category/CategorySelection';
import type { CategorySelectionChangeCommand } from '@/application/Context/State/Selection/Category/CategorySelectionChange';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
export class CategorySelectionStub
extends StubWithObservableMethodCalls<CategorySelection>
implements CategorySelection {
public isCategorySelected(categoryId: number, revert: boolean): boolean {
public isCategorySelected(categoryId: ExecutableId, revert: boolean): boolean {
const call = this.callHistory.find(
(c) => c.methodName === 'processChanges'
&& c.args[0].changes.some((change) => (

View File

@@ -1,13 +1,13 @@
import { BaseEntity } from '@/infrastructure/Entity/BaseEntity';
import type { Category } from '@/domain/Executables/Category/Category';
import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
import type { Script } from '@/domain/Executables/Script/Script';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import { ScriptStub } from './ScriptStub';
export class CategoryStub extends BaseEntity<number> implements Category {
public name = `category-with-id-${this.id}`;
export class CategoryStub implements Category {
public name = `[${CategoryStub.name}] name (ID: ${this.executableId})`;
public readonly subCategories = new Array<Category>();
public readonly subcategories = new Array<Category>();
public readonly scripts = new Array<Script>();
@@ -15,25 +15,25 @@ export class CategoryStub extends BaseEntity<number> implements Category {
private allScriptsRecursively: (readonly Script[]) | undefined;
public constructor(id: number) {
super(id);
}
public constructor(
readonly executableId: ExecutableId,
) { }
public includes(script: Script): boolean {
return this.getAllScriptsRecursively().some((s) => s.id === script.id);
return this.getAllScriptsRecursively().some((s) => s.executableId === script.executableId);
}
public getAllScriptsRecursively(): readonly Script[] {
if (this.allScriptsRecursively === undefined) {
return [
...this.scripts,
...this.subCategories.flatMap((c) => c.getAllScriptsRecursively()),
...this.subcategories.flatMap((c) => c.getAllScriptsRecursively()),
];
}
return this.allScriptsRecursively;
}
public withScriptIds(...scriptIds: readonly string[]): this {
public withScriptIds(...scriptIds: readonly ExecutableId[]): this {
return this.withScripts(
...scriptIds.map((id) => new ScriptStub(id)),
);
@@ -70,7 +70,7 @@ export class CategoryStub extends BaseEntity<number> implements Category {
}
public withCategory(category: Category): this {
this.subCategories.push(category);
this.subcategories.push(category);
return this;
}

View File

@@ -19,8 +19,13 @@ export class FilterResultStub implements FilterResult {
public withSomeMatches() {
return this
.withCategoryMatches([new CategoryStub(3).withScriptIds('script-1')])
.withScriptMatches([new ScriptStub('script-2')]);
.withCategoryMatches([
new CategoryStub(`[${FilterResultStub.name}]-matched-category-1`)
.withScriptIds(`[${FilterResultStub.name}]-matched-script-1`),
])
.withScriptMatches([
new ScriptStub(`[${FilterResultStub.name}]-matched-script-2`),
]);
}
public withCategoryMatches(matches: readonly Category[]) {

View File

@@ -1,6 +1,6 @@
import type { FilterResult } from '@/application/Context/State/Filter/Result/FilterResult';
import type { FilterStrategy } from '@/application/Context/State/Filter/Strategy/FilterStrategy';
import type { ICategoryCollection } from '@/domain/ICategoryCollection';
import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { FilterResultStub } from './FilterResultStub';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';

View File

@@ -1,7 +1,8 @@
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import { type NodeMetadata, NodeType } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata';
export class NodeMetadataStub implements NodeMetadata {
public id = 'stub-id';
public executableId: ExecutableId = 'stub-id';
public readonly text: string = 'stub-text';
@@ -18,8 +19,8 @@ export class NodeMetadataStub implements NodeMetadata {
return this;
}
public withId(id: string): this {
this.id = id;
public withId(executableId: ExecutableId): this {
this.executableId = executableId;
return this;
}
}

View File

@@ -1,14 +0,0 @@
import { BaseEntity } from '@/infrastructure/Entity/BaseEntity';
export class NumericEntityStub extends BaseEntity<number> {
public customProperty = 'customProperty';
public constructor(id: number) {
super(id);
}
public withCustomProperty(value: string): NumericEntityStub {
this.customProperty = value;
return this;
}
}

View File

@@ -0,0 +1,14 @@
import type { RepositoryEntity, RepositoryEntityId } from '@/application/Repository/RepositoryEntity';
export class RepositoryEntityStub implements RepositoryEntity {
public customProperty = 'customProperty';
public constructor(
public readonly id: RepositoryEntityId,
) { }
public withCustomPropertyValue(value: string): RepositoryEntityStub {
this.customProperty = value;
return this;
}
}

View File

@@ -1,6 +1,5 @@
import type { ScriptFactory } from '@/application/Parser/Executable/Script/ScriptParser';
import type { Script } from '@/domain/Executables/Script/Script';
import type { ScriptInitParameters } from '@/domain/Executables/Script/CollectionScript';
import type { ScriptFactory, ScriptInitParameters } from '@/domain/Executables/Script/ScriptFactory';
import { ScriptStub } from './ScriptStub';
export function createScriptFactorySpy(): {

View File

@@ -4,6 +4,7 @@ import type { SelectedScript } from '@/application/Context/State/Selection/Scrip
import type { Script } from '@/domain/Executables/Script/Script';
import type { ScriptSelectionChange, ScriptSelectionChangeCommand } from '@/application/Context/State/Selection/Script/ScriptSelectionChange';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
import { EventSourceStub } from './EventSourceStub';
import { SelectedScriptStub } from './SelectedScriptStub';
@@ -32,9 +33,9 @@ export class ScriptSelectionStub
return this;
}
public isScriptSelected(scriptId: string, revert: boolean): boolean {
public isScriptSelected(scriptExecutableId: ExecutableId, revert: boolean): boolean {
return this.isScriptChanged({
scriptId,
scriptId: scriptExecutableId,
newStatus: {
isSelected: true,
isReverted: revert,
@@ -42,9 +43,9 @@ export class ScriptSelectionStub
});
}
public isScriptDeselected(scriptId: string): boolean {
public isScriptDeselected(scriptExecutableId: ExecutableId): boolean {
return this.isScriptChanged({
scriptId,
scriptId: scriptExecutableId,
newStatus: {
isSelected: false,
},

View File

@@ -1,15 +1,15 @@
import { BaseEntity } from '@/infrastructure/Entity/BaseEntity';
import type { Script } from '@/domain/Executables/Script/Script';
import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import { SelectedScriptStub } from './SelectedScriptStub';
export class ScriptStub extends BaseEntity<string> implements Script {
public name = `name${this.id}`;
export class ScriptStub implements Script {
public name = `name${this.executableId}`;
public code: ScriptCode = {
execute: `REM execute-code (${this.id})`,
revert: `REM revert-code (${this.id})`,
execute: `REM execute-code (${this.executableId})`,
revert: `REM revert-code (${this.executableId})`,
};
public docs: readonly string[] = new Array<string>();
@@ -18,9 +18,7 @@ export class ScriptStub extends BaseEntity<string> implements Script {
private isReversible: boolean | undefined = undefined;
constructor(public readonly id: string) {
super(id);
}
constructor(public readonly executableId: ExecutableId) { }
public canRevert(): boolean {
if (this.isReversible === undefined) {

View File

@@ -1,17 +1,18 @@
import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import type { RepositoryEntityId } from '@/application/Repository/RepositoryEntity';
import type { Script } from '@/domain/Executables/Script/Script';
export class SelectedScriptStub implements SelectedScript {
public readonly script: Script;
public readonly id: string;
public readonly id: RepositoryEntityId;
public revert: boolean;
constructor(
script: Script,
) {
this.id = script.id;
this.id = script.executableId;
this.script = script;
}

View File

@@ -1,7 +1,8 @@
import type { TreeInputNodeData } from '@/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeInputNodeData';
import type { TreeNodeId } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
export class TreeInputNodeDataStub implements TreeInputNodeData {
public id = 'stub-id';
public id: TreeNodeId = 'stub-id';
public children?: readonly TreeInputNodeData[];
@@ -14,7 +15,7 @@ export class TreeInputNodeDataStub implements TreeInputNodeData {
return this;
}
public withId(id: string): this {
public withId(id: TreeNodeId): this {
this.id = id;
return this;
}

View File

@@ -1,6 +1,6 @@
import type { HierarchyAccess } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/Hierarchy/HierarchyAccess';
import type { TreeNodeStateAccess } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateAccess';
import type { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
import type { TreeNode, TreeNodeId } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
import { NodeMetadataStub } from './NodeMetadataStub';
import { HierarchyAccessStub } from './HierarchyAccessStub';
import { TreeNodeStateAccessStub } from './TreeNodeStateAccessStub';
@@ -39,7 +39,7 @@ export class TreeNodeStub implements TreeNode {
return this;
}
public withId(id: string): this {
public withId(id: TreeNodeId): this {
this.id = id;
return this;
}