From ded55a66d6044a03d4e18330e146b69d159509a3 Mon Sep 17 00:00:00 2001 From: undergroundwires Date: Sat, 3 Aug 2024 16:54:14 +0200 Subject: [PATCH] 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. --- docs/collection-files.md | 2 + src/application/Context/ApplicationContext.ts | 2 +- .../Context/State/CategoryCollectionState.ts | 2 +- .../State/Code/Event/CodeChangedEvent.ts | 7 +- .../State/Filter/AdaptiveFilterContext.ts | 2 +- .../State/Filter/Strategy/FilterStrategy.ts | 2 +- .../Filter/Strategy/LinearFilterStrategy.ts | 2 +- .../Context/State/ICategoryCollectionState.ts | 2 +- .../Category/CategorySelectionChange.ts | 4 +- .../ScriptToCategorySelectionMapper.ts | 6 +- .../Script/DebouncedScriptSelection.ts | 33 +- .../State/Selection/Script/ScriptSelection.ts | 3 +- .../Selection/Script/ScriptSelectionChange.ts | 4 +- .../State/Selection/Script/SelectedScript.ts | 6 +- .../Selection/Script/UserSelectedScript.ts | 11 +- .../State/Selection/UserSelectionFacade.ts | 2 +- .../Parser/CategoryCollectionParser.ts | 4 +- .../Parser/Executable/CategoryParser.ts | 12 +- .../Parser/Executable/Script/ScriptParser.ts | 11 +- src/application/Repository/Repository.ts | 18 +- .../Repository/RepositoryEntity.ts | 6 + src/application/collections/windows.yaml | 6 +- src/domain/Application.ts | 2 +- .../{ => Collection}/CategoryCollection.ts | 80 +--- .../{ => Collection}/ICategoryCollection.ts | 5 +- .../Validation/CategoryCollectionValidator.ts | 15 + .../CompositeCategoryCollectionValidator.ts | 33 ++ .../Rules/EnsureKnownOperatingSystem.ts | 9 + ...EnsurePresenceOfAllRecommendationLevels.ts | 35 ++ .../EnsurePresenceOfAtLeastOneCategory.ts | 9 + .../Rules/EnsurePresenceOfAtLeastOneScript.ts | 9 + .../Rules/EnsureUniqueIdsAcrossExecutables.ts | 43 ++ src/domain/Executables/Category/Category.ts | 5 +- ...llectionCategory.ts => CategoryFactory.ts} | 53 ++- src/domain/Executables/Executable.ts | 6 +- src/domain/Executables/Identifiable.ts | 5 + src/domain/Executables/Script/Script.ts | 2 +- .../{CollectionScript.ts => ScriptFactory.ts} | 33 +- src/domain/IApplication.ts | 2 +- src/infrastructure/Entity/BaseEntity.ts | 14 - src/infrastructure/Entity/IEntity.ts | 5 - .../Repository/InMemoryRepository.ts | 19 +- .../RecommendationStatusHandler.ts | 4 +- .../TheRecommendationSelector.vue | 2 +- .../Scripts/View/Cards/CardList.vue | 9 +- .../Scripts/View/Cards/CardListItem.vue | 6 +- .../View/Cards/CardSelectionIndicator.vue | 7 +- .../View/Tree/NodeContent/NodeMetadata.ts | 4 +- .../View/Tree/NodeContent/RevertToggle.vue | 2 +- .../NodeContent/Reverter/CategoryReverter.ts | 16 +- .../NodeContent/Reverter/ReverterFactory.ts | 11 +- .../NodeContent/Reverter/ScriptReverter.ts | 10 +- .../Scripts/View/Tree/ScriptsTree.vue | 5 +- .../TreeView/Bindings/TreeInputNodeData.ts | 4 +- .../TreeView/Node/HierarchicalTreeNode.vue | 4 +- .../Tree/TreeView/Node/InteractableNode.vue | 4 +- .../View/Tree/TreeView/Node/LeafTreeNode.vue | 4 +- .../View/Tree/TreeView/Node/NodeCheckbox.vue | 4 +- .../View/Tree/TreeView/Node/TreeNode.ts | 4 +- .../Tree/TreeView/Node/TreeNodeManager.ts | 4 +- .../NodeCollection/Query/QueryableNodes.ts | 6 +- .../NodeCollection/Query/TreeNodeNavigator.ts | 8 +- .../View/Tree/TreeView/TreeRoot/TreeRoot.vue | 3 +- .../Scripts/View/Tree/TreeView/TreeView.vue | 3 +- .../CategoryNodeMetadataConverter.ts | 27 +- .../TreeNodeMetadataConverter.ts | 2 +- .../UseSelectedScriptNodeIds.ts | 9 +- .../TreeViewAdapter/UseTreeViewFilterEvent.ts | 31 +- .../TreeViewAdapter/UseTreeViewNodeInput.ts | 7 +- .../CompositeMarkdownRenderer.spec.ts | 2 +- .../Context/ApplicationContextFactory.spec.ts | 2 +- .../State/CategoryCollectionState.spec.ts | 2 +- .../Filter/AdaptiveFilterContext.spec.ts | 2 +- .../Filter/Result/AppliedFilterResult.spec.ts | 16 +- .../Strategy/LinearFilterStrategy.spec.ts | 19 +- .../ScriptToCategorySelectionMapper.spec.ts | 73 ++- .../Script/DebouncedScriptSelection.spec.ts | 70 ++- .../Selection/UserSelectionFacade.spec.ts | 2 +- .../Parser/ApplicationParser.spec.ts | 2 +- .../Parser/CategoryCollectionParser.spec.ts | 5 +- .../Parser/Executable/CategoryParser.spec.ts | 377 +++++++++------- .../Executable/Script/ScriptParser.spec.ts | 427 +++++++++--------- tests/unit/domain/Application.spec.ts | 2 +- .../CategoryCollection.spec.ts | 149 ++---- ...mpositeCategoryCollectionValidator.spec.ts | 170 +++++++ .../Rules/EnsureKnownOperatingSystem.spec.ts | 21 + ...ePresenceOfAllRecommendationLevels.spec.ts | 99 ++++ ...EnsurePresenceOfAtLeastOneCategory.spec.ts | 39 ++ .../EnsurePresenceOfAtLeastOneScript.spec.ts | 39 ++ .../EnsureUniqueIdsAcrossExecutables.spec.ts | 146 ++++++ .../Category/CategoryFactory.spec.ts | 316 +++++++++++++ .../Category/CollectionCategory.spec.ts | 217 --------- ...onScript.spec.ts => ScriptFactory.spec.ts} | 79 ++-- .../infrastructure/InMemoryRepository.spec.ts | 217 +++++---- .../RecommendationStatusHandler.spec.ts | 2 +- .../Reverter/CategoryReverter.spec.ts | 12 +- .../Reverter/ReverterFactory.spec.ts | 19 +- .../Reverter/ScriptReverter.spec.ts | 10 +- .../TreeView/Node/TreeNodeManager.spec.ts | 6 +- .../CategoryNodeMetadataConverter.spec.ts | 89 ++-- .../UseSelectedScriptNodeIds.spec.ts | 18 +- .../UseTreeViewFilterEvent.spec.ts | 32 +- .../UseTreeViewNodeInput.spec.ts | 15 +- tests/unit/shared/Stubs/ApplicationStub.ts | 2 +- .../Stubs/CategoryCollectionFactoryStub.ts | 4 +- .../Stubs/CategoryCollectionParserStub.ts | 2 +- .../Stubs/CategoryCollectionStateStub.ts | 7 +- .../shared/Stubs/CategoryCollectionStub.ts | 23 +- ...CategoryCollectionValidationContextStub.ts | 34 ++ .../unit/shared/Stubs/CategoryFactoryStub.ts | 5 +- tests/unit/shared/Stubs/CategoryParserStub.ts | 2 +- .../shared/Stubs/CategorySelectionStub.ts | 3 +- tests/unit/shared/Stubs/CategoryStub.ts | 22 +- tests/unit/shared/Stubs/FilterResultStub.ts | 9 +- tests/unit/shared/Stubs/FilterStrategyStub.ts | 2 +- tests/unit/shared/Stubs/NodeMetadataStub.ts | 7 +- tests/unit/shared/Stubs/NumericEntityStub.ts | 14 - .../unit/shared/Stubs/RepositoryEntityStub.ts | 14 + tests/unit/shared/Stubs/ScriptFactoryStub.ts | 3 +- .../unit/shared/Stubs/ScriptSelectionStub.ts | 9 +- tests/unit/shared/Stubs/ScriptStub.ts | 14 +- tests/unit/shared/Stubs/SelectedScriptStub.ts | 5 +- .../shared/Stubs/TreeInputNodeDataStub.ts | 5 +- tests/unit/shared/Stubs/TreeNodeStub.ts | 4 +- 124 files changed, 2286 insertions(+), 1331 deletions(-) create mode 100644 src/application/Repository/RepositoryEntity.ts rename src/domain/{ => Collection}/CategoryCollection.ts (54%) rename src/domain/{ => Collection}/ICategoryCollection.ts (82%) create mode 100644 src/domain/Collection/Validation/CategoryCollectionValidator.ts create mode 100644 src/domain/Collection/Validation/CompositeCategoryCollectionValidator.ts create mode 100644 src/domain/Collection/Validation/Rules/EnsureKnownOperatingSystem.ts create mode 100644 src/domain/Collection/Validation/Rules/EnsurePresenceOfAllRecommendationLevels.ts create mode 100644 src/domain/Collection/Validation/Rules/EnsurePresenceOfAtLeastOneCategory.ts create mode 100644 src/domain/Collection/Validation/Rules/EnsurePresenceOfAtLeastOneScript.ts create mode 100644 src/domain/Collection/Validation/Rules/EnsureUniqueIdsAcrossExecutables.ts rename src/domain/Executables/Category/{CollectionCategory.ts => CategoryFactory.ts} (57%) create mode 100644 src/domain/Executables/Identifiable.ts rename src/domain/Executables/Script/{CollectionScript.ts => ScriptFactory.ts} (69%) delete mode 100644 src/infrastructure/Entity/BaseEntity.ts delete mode 100644 src/infrastructure/Entity/IEntity.ts rename tests/unit/domain/{ => Collection}/CategoryCollection.spec.ts (57%) create mode 100644 tests/unit/domain/Collection/Validation/CompositeCategoryCollectionValidator.spec.ts create mode 100644 tests/unit/domain/Collection/Validation/Rules/EnsureKnownOperatingSystem.spec.ts create mode 100644 tests/unit/domain/Collection/Validation/Rules/EnsurePresenceOfAllRecommendationLevels.spec.ts create mode 100644 tests/unit/domain/Collection/Validation/Rules/EnsurePresenceOfAtLeastOneCategory.spec.ts create mode 100644 tests/unit/domain/Collection/Validation/Rules/EnsurePresenceOfAtLeastOneScript.spec.ts create mode 100644 tests/unit/domain/Collection/Validation/Rules/EnsureUniqueIdsAcrossExecutables.spec.ts create mode 100644 tests/unit/domain/Executables/Category/CategoryFactory.spec.ts delete mode 100644 tests/unit/domain/Executables/Category/CollectionCategory.spec.ts rename tests/unit/domain/Executables/Script/{CollectionScript.spec.ts => ScriptFactory.spec.ts} (60%) create mode 100644 tests/unit/shared/Stubs/CategoryCollectionValidationContextStub.ts delete mode 100644 tests/unit/shared/Stubs/NumericEntityStub.ts create mode 100644 tests/unit/shared/Stubs/RepositoryEntityStub.ts diff --git a/docs/collection-files.md b/docs/collection-files.md index 414fd8ea..c4878397 100644 --- a/docs/collection-files.md +++ b/docs/collection-files.md @@ -30,6 +30,8 @@ Related documentation: ### Executables +They represent independently executable actions with documentation and reversibility. + An Executable is a logical entity that can - execute once compiled, diff --git a/src/application/Context/ApplicationContext.ts b/src/application/Context/ApplicationContext.ts index 7829737f..4828947a 100644 --- a/src/application/Context/ApplicationContext.ts +++ b/src/application/Context/ApplicationContext.ts @@ -1,6 +1,6 @@ import type { IApplication } from '@/domain/IApplication'; 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 { assertInRange } from '@/application/Common/Enum'; import { CategoryCollectionState } from './State/CategoryCollectionState'; diff --git a/src/application/Context/State/CategoryCollectionState.ts b/src/application/Context/State/CategoryCollectionState.ts index 253a7133..6d37ed8f 100644 --- a/src/application/Context/State/CategoryCollectionState.ts +++ b/src/application/Context/State/CategoryCollectionState.ts @@ -1,4 +1,4 @@ -import type { ICategoryCollection } from '@/domain/ICategoryCollection'; +import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection'; import { OperatingSystem } from '@/domain/OperatingSystem'; import { AdaptiveFilterContext } from './Filter/AdaptiveFilterContext'; import { ApplicationCode } from './Code/ApplicationCode'; diff --git a/src/application/Context/State/Code/Event/CodeChangedEvent.ts b/src/application/Context/State/Code/Event/CodeChangedEvent.ts index 916483f2..7f19636f 100644 --- a/src/application/Context/State/Code/Event/CodeChangedEvent.ts +++ b/src/application/Context/State/Code/Event/CodeChangedEvent.ts @@ -2,6 +2,7 @@ import type { Script } from '@/domain/Executables/Script/Script'; import type { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition'; import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript'; import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines'; +import type { ExecutableId } from '@/domain/Executables/Identifiable'; import type { ICodeChangedEvent } from './ICodeChangedEvent'; export class CodeChangedEvent implements ICodeChangedEvent { @@ -37,12 +38,12 @@ export class CodeChangedEvent implements ICodeChangedEvent { } 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()] - .filter(([s]) => s.id === scriptId) + .filter(([s]) => s.executableId === scriptId) .map(([, pos]) => pos) .at(0); if (!position) { diff --git a/src/application/Context/State/Filter/AdaptiveFilterContext.ts b/src/application/Context/State/Filter/AdaptiveFilterContext.ts index 902ec523..6ecba461 100644 --- a/src/application/Context/State/Filter/AdaptiveFilterContext.ts +++ b/src/application/Context/State/Filter/AdaptiveFilterContext.ts @@ -1,5 +1,5 @@ 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 { LinearFilterStrategy } from './Strategy/LinearFilterStrategy'; import type { FilterResult } from './Result/FilterResult'; diff --git a/src/application/Context/State/Filter/Strategy/FilterStrategy.ts b/src/application/Context/State/Filter/Strategy/FilterStrategy.ts index 2bda8de2..2a4a7862 100644 --- a/src/application/Context/State/Filter/Strategy/FilterStrategy.ts +++ b/src/application/Context/State/Filter/Strategy/FilterStrategy.ts @@ -1,4 +1,4 @@ -import type { ICategoryCollection } from '@/domain/ICategoryCollection'; +import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection'; import type { FilterResult } from '../Result/FilterResult'; export interface FilterStrategy { diff --git a/src/application/Context/State/Filter/Strategy/LinearFilterStrategy.ts b/src/application/Context/State/Filter/Strategy/LinearFilterStrategy.ts index bb3bfb04..327bdf80 100644 --- a/src/application/Context/State/Filter/Strategy/LinearFilterStrategy.ts +++ b/src/application/Context/State/Filter/Strategy/LinearFilterStrategy.ts @@ -1,7 +1,7 @@ import type { Category } from '@/domain/Executables/Category/Category'; import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode'; 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 { AppliedFilterResult } from '../Result/AppliedFilterResult'; import type { FilterStrategy } from './FilterStrategy'; diff --git a/src/application/Context/State/ICategoryCollectionState.ts b/src/application/Context/State/ICategoryCollectionState.ts index 8a485679..3d9bdaa5 100644 --- a/src/application/Context/State/ICategoryCollectionState.ts +++ b/src/application/Context/State/ICategoryCollectionState.ts @@ -1,4 +1,4 @@ -import type { ICategoryCollection } from '@/domain/ICategoryCollection'; +import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection'; import { OperatingSystem } from '@/domain/OperatingSystem'; import type { IApplicationCode } from './Code/IApplicationCode'; import type { ReadonlyFilterContext, FilterContext } from './Filter/FilterContext'; diff --git a/src/application/Context/State/Selection/Category/CategorySelectionChange.ts b/src/application/Context/State/Selection/Category/CategorySelectionChange.ts index a944a3f6..36b7de90 100644 --- a/src/application/Context/State/Selection/Category/CategorySelectionChange.ts +++ b/src/application/Context/State/Selection/Category/CategorySelectionChange.ts @@ -1,3 +1,5 @@ +import type { ExecutableId } from '@/domain/Executables/Identifiable'; + type CategorySelectionStatus = { readonly isSelected: true; readonly isReverted: boolean; @@ -6,7 +8,7 @@ type CategorySelectionStatus = { }; export interface CategorySelectionChange { - readonly categoryId: number; + readonly categoryId: ExecutableId; readonly newStatus: CategorySelectionStatus; } diff --git a/src/application/Context/State/Selection/Category/ScriptToCategorySelectionMapper.ts b/src/application/Context/State/Selection/Category/ScriptToCategorySelectionMapper.ts index a856b0ea..a2916926 100644 --- a/src/application/Context/State/Selection/Category/ScriptToCategorySelectionMapper.ts +++ b/src/application/Context/State/Selection/Category/ScriptToCategorySelectionMapper.ts @@ -1,5 +1,5 @@ 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 { CategorySelection } from './CategorySelection'; import type { ScriptSelection } from '../Script/ScriptSelection'; @@ -23,7 +23,7 @@ export class ScriptToCategorySelectionMapper implements CategorySelection { return false; } 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 scriptsChangesInCategory = scripts .map((script): ScriptSelectionChange => ({ - scriptId: script.id, + scriptId: script.executableId, newStatus: { ...change.newStatus, }, diff --git a/src/application/Context/State/Selection/Script/DebouncedScriptSelection.ts b/src/application/Context/State/Selection/Script/DebouncedScriptSelection.ts index 79a7dd46..9898b82f 100644 --- a/src/application/Context/State/Selection/Script/DebouncedScriptSelection.ts +++ b/src/application/Context/State/Selection/Script/DebouncedScriptSelection.ts @@ -2,8 +2,9 @@ import { InMemoryRepository } from '@/infrastructure/Repository/InMemoryReposito import type { Script } from '@/domain/Executables/Script/Script'; import { EventSource } from '@/infrastructure/Events/EventSource'; 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 type { ExecutableId } from '@/domain/Executables/Identifiable'; import { UserSelectedScript } from './UserSelectedScript'; import type { ScriptSelection } from './ScriptSelection'; import type { ScriptSelectionChange, ScriptSelectionChangeCommand } from './ScriptSelectionChange'; @@ -16,7 +17,7 @@ export type DebounceFunction = typeof batchedDebounce>(); - private readonly scripts: Repository; + private readonly scripts: Repository; public readonly processChanges: ScriptSelection['processChanges']; @@ -25,7 +26,7 @@ export class DebouncedScriptSelection implements ScriptSelection { selectedScripts: ReadonlyArray, debounce: DebounceFunction = batchedDebounce, ) { - this.scripts = new InMemoryRepository(); + this.scripts = new InMemoryRepository(); for (const script of selectedScripts) { this.scripts.addItem(script); } @@ -38,8 +39,8 @@ export class DebouncedScriptSelection implements ScriptSelection { ); } - public isSelected(scriptId: string): boolean { - return this.scripts.exists(scriptId); + public isSelected(scriptExecutableId: ExecutableId): boolean { + return this.scripts.exists(scriptExecutableId); } public get selectedScripts(): readonly SelectedScript[] { @@ -49,7 +50,7 @@ export class DebouncedScriptSelection implements ScriptSelection { public selectAll(): void { const scriptsToSelect = this.collection .getAllScripts() - .filter((script) => !this.scripts.exists(script.id)) + .filter((script) => !this.scripts.exists(script.executableId)) .map((script) => new UserSelectedScript(script, false)); if (scriptsToSelect.length === 0) { return; @@ -116,12 +117,12 @@ export class DebouncedScriptSelection implements ScriptSelection { private applyChange(change: ScriptSelectionChange): number { const script = this.collection.getScript(change.scriptId); if (change.newStatus.isSelected) { - return this.addOrUpdateScript(script.id, change.newStatus.isReverted); + return this.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 selectedScript = new UserSelectedScript(script, revert); if (!this.scripts.exists(selectedScript.id)) { @@ -136,7 +137,7 @@ export class DebouncedScriptSelection implements ScriptSelection { return 1; } - private removeScript(scriptId: string): number { + private removeScript(scriptId: ExecutableId): number { if (!this.scripts.exists(scriptId)) { return 0; } @@ -152,24 +153,24 @@ function assertNonEmptyScriptSelection(selectedItems: readonly Script[]) { } function getScriptIdsToBeSelected( - existingItems: ReadonlyRepository, + existingItems: ReadonlyRepository, desiredScripts: readonly Script[], ): string[] { return desiredScripts - .filter((script) => !existingItems.exists(script.id)) - .map((script) => script.id); + .filter((script) => !existingItems.exists(script.executableId)) + .map((script) => script.executableId); } function getScriptIdsToBeDeselected( - existingItems: ReadonlyRepository, + existingItems: ReadonlyRepository, desiredScripts: readonly Script[], ): string[] { return existingItems .getItems() - .filter((existing) => !desiredScripts.some((script) => existing.id === script.id)) + .filter((existing) => !desiredScripts.some((script) => existing.id === script.executableId)) .map((script) => script.id); } 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; } diff --git a/src/application/Context/State/Selection/Script/ScriptSelection.ts b/src/application/Context/State/Selection/Script/ScriptSelection.ts index ebd7f3e9..880041d9 100644 --- a/src/application/Context/State/Selection/Script/ScriptSelection.ts +++ b/src/application/Context/State/Selection/Script/ScriptSelection.ts @@ -1,12 +1,13 @@ import type { IEventSource } from '@/infrastructure/Events/IEventSource'; import type { Script } from '@/domain/Executables/Script/Script'; +import type { ExecutableId } from '@/domain/Executables/Identifiable'; import type { SelectedScript } from './SelectedScript'; import type { ScriptSelectionChangeCommand } from './ScriptSelectionChange'; export interface ReadonlyScriptSelection { readonly changed: IEventSource; readonly selectedScripts: readonly SelectedScript[]; - isSelected(scriptId: string): boolean; + isSelected(scriptExecutableId: ExecutableId): boolean; } export interface ScriptSelection extends ReadonlyScriptSelection { diff --git a/src/application/Context/State/Selection/Script/ScriptSelectionChange.ts b/src/application/Context/State/Selection/Script/ScriptSelectionChange.ts index 16f23a31..eae5dc9f 100644 --- a/src/application/Context/State/Selection/Script/ScriptSelectionChange.ts +++ b/src/application/Context/State/Selection/Script/ScriptSelectionChange.ts @@ -1,3 +1,5 @@ +import type { ExecutableId } from '@/domain/Executables/Identifiable'; + export type ScriptSelectionStatus = { readonly isSelected: true; readonly isReverted: boolean; @@ -7,7 +9,7 @@ export type ScriptSelectionStatus = { }; export interface ScriptSelectionChange { - readonly scriptId: string; + readonly scriptId: ExecutableId; readonly newStatus: ScriptSelectionStatus; } diff --git a/src/application/Context/State/Selection/Script/SelectedScript.ts b/src/application/Context/State/Selection/Script/SelectedScript.ts index c0059f6e..858dbbbb 100644 --- a/src/application/Context/State/Selection/Script/SelectedScript.ts +++ b/src/application/Context/State/Selection/Script/SelectedScript.ts @@ -1,9 +1,7 @@ -import type { IEntity } from '@/infrastructure/Entity/IEntity'; import type { Script } from '@/domain/Executables/Script/Script'; +import type { RepositoryEntity } from '@/application/Repository/RepositoryEntity'; -type ScriptId = Script['id']; - -export interface SelectedScript extends IEntity { +export interface SelectedScript extends RepositoryEntity { readonly script: Script; readonly revert: boolean; } diff --git a/src/application/Context/State/Selection/Script/UserSelectedScript.ts b/src/application/Context/State/Selection/Script/UserSelectedScript.ts index 315da002..01b40b38 100644 --- a/src/application/Context/State/Selection/Script/UserSelectedScript.ts +++ b/src/application/Context/State/Selection/Script/UserSelectedScript.ts @@ -1,17 +1,16 @@ -import { BaseEntity } from '@/infrastructure/Entity/BaseEntity'; 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 { constructor( public readonly script: Script, public readonly revert: boolean, ) { - super(script.id); + this.id = script.executableId; 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.`); } } } diff --git a/src/application/Context/State/Selection/UserSelectionFacade.ts b/src/application/Context/State/Selection/UserSelectionFacade.ts index 7667d983..6fbc12bc 100644 --- a/src/application/Context/State/Selection/UserSelectionFacade.ts +++ b/src/application/Context/State/Selection/UserSelectionFacade.ts @@ -1,4 +1,4 @@ -import type { ICategoryCollection } from '@/domain/ICategoryCollection'; +import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection'; import { ScriptToCategorySelectionMapper } from './Category/ScriptToCategorySelectionMapper'; import { DebouncedScriptSelection } from './Script/DebouncedScriptSelection'; import type { CategorySelection } from './Category/CategorySelection'; diff --git a/src/application/Parser/CategoryCollectionParser.ts b/src/application/Parser/CategoryCollectionParser.ts index be10a9d0..b7e71420 100644 --- a/src/application/Parser/CategoryCollectionParser.ts +++ b/src/application/Parser/CategoryCollectionParser.ts @@ -1,7 +1,7 @@ import type { CollectionData } from '@/application/collections/'; import { OperatingSystem } from '@/domain/OperatingSystem'; -import type { ICategoryCollection } from '@/domain/ICategoryCollection'; -import { CategoryCollection } from '@/domain/CategoryCollection'; +import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection'; +import { CategoryCollection } from '@/domain/Collection/CategoryCollection'; import type { ProjectDetails } from '@/domain/Project/ProjectDetails'; import { createEnumParser, type EnumParser } from '../Common/Enum'; import { parseCategory, type CategoryParser } from './Executable/CategoryParser'; diff --git a/src/application/Parser/Executable/CategoryParser.ts b/src/application/Parser/Executable/CategoryParser.ts index 88d77a1b..c2a65e28 100644 --- a/src/application/Parser/Executable/CategoryParser.ts +++ b/src/application/Parser/Executable/CategoryParser.ts @@ -3,16 +3,14 @@ import type { } from '@/application/collections/'; import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError'; import type { Category } from '@/domain/Executables/Category/Category'; -import { CollectionCategory } from '@/domain/Executables/Category/CollectionCategory'; import type { Script } from '@/domain/Executables/Script/Script'; +import { createCategory, type CategoryFactory } from '@/domain/Executables/Category/CategoryFactory'; import { parseDocs, type DocsParser } from './DocumentationParser'; import { parseScript, type ScriptParser } from './Script/ScriptParser'; import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from './Validation/ExecutableValidator'; import { ExecutableType } from './Validation/ExecutableType'; import type { CategoryCollectionSpecificUtilities } from './CategoryCollectionSpecificUtilities'; -let categoryIdCounter = 0; - export const parseCategory: CategoryParser = ( category: CategoryData, collectionUtilities: CategoryCollectionSpecificUtilities, @@ -59,7 +57,7 @@ function parseCategoryRecursively( } try { return context.categoryUtilities.createCategory({ - id: categoryIdCounter++, + executableId: context.categoryData.category, // Pseudo-ID for uniqueness until real ID support name: context.categoryData.category, docs: context.categoryUtilities.parseDocs(context.categoryData), subcategories: children.subcategories, @@ -166,10 +164,6 @@ function hasProperty( return Object.prototype.hasOwnProperty.call(object, propertyName); } -export type CategoryFactory = ( - ...parameters: ConstructorParameters -) => Category; - interface CategoryParserUtilities { readonly createCategory: CategoryFactory; readonly wrapError: ErrorWithContextWrapper; @@ -179,7 +173,7 @@ interface CategoryParserUtilities { } const DefaultCategoryParserUtilities: CategoryParserUtilities = { - createCategory: (...parameters) => new CollectionCategory(...parameters), + createCategory, wrapError: wrapErrorWithAdditionalContext, createValidator: createExecutableDataValidator, parseScript, diff --git a/src/application/Parser/Executable/Script/ScriptParser.ts b/src/application/Parser/Executable/Script/ScriptParser.ts index d7be9d0c..5aa9b940 100644 --- a/src/application/Parser/Executable/Script/ScriptParser.ts +++ b/src/application/Parser/Executable/Script/ScriptParser.ts @@ -1,7 +1,6 @@ import type { ScriptData, CodeScriptData, CallScriptData } from '@/application/collections/'; import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines'; 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 type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode'; 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 { createEnumParser, type EnumParser } from '@/application/Common/Enum'; import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings'; +import { createScript, type ScriptFactory } from '@/domain/Executables/Script/ScriptFactory'; import { parseDocs, type DocsParser } from '../DocumentationParser'; import { ExecutableType } from '../Validation/ExecutableType'; import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from '../Validation/ExecutableValidator'; @@ -38,6 +38,7 @@ export const parseScript: ScriptParser = ( validateScript(data, validator); try { const script = scriptUtilities.createScript({ + executableId: data.name, // Pseudo-ID for uniqueness until real ID support name: data.name, code: parseCode( data, @@ -132,14 +133,6 @@ interface ScriptParserUtilities { readonly parseDocs: DocsParser; } -export type ScriptFactory = ( - ...parameters: ConstructorParameters -) => Script; - -const createScript: ScriptFactory = (...parameters) => { - return new CollectionScript(...parameters); -}; - const DefaultUtilities: ScriptParserUtilities = { levelParser: createEnumParser(RecommendationLevel), createScript, diff --git a/src/application/Repository/Repository.ts b/src/application/Repository/Repository.ts index 6232598a..0b2d83d7 100644 --- a/src/application/Repository/Repository.ts +++ b/src/application/Repository/Repository.ts @@ -1,17 +1,19 @@ -import type { IEntity } from '@/infrastructure/Entity/IEntity'; +import type { RepositoryEntity } from './RepositoryEntity'; -export interface ReadonlyRepository> { +type EntityId = RepositoryEntity['id']; + +export interface ReadonlyRepository { readonly length: number; getItems(predicate?: (entity: TEntity) => boolean): readonly TEntity[]; - getById(id: TKey): TEntity; - exists(id: TKey): boolean; + getById(id: EntityId): TEntity; + exists(id: EntityId): boolean; } -export interface MutableRepository> { +export interface MutableRepository { addItem(item: TEntity): void; addOrUpdateItem(item: TEntity): void; - removeItem(id: TKey): void; + removeItem(id: EntityId): void; } -export interface Repository> - extends ReadonlyRepository, MutableRepository { } +export interface Repository + extends ReadonlyRepository, MutableRepository { } diff --git a/src/application/Repository/RepositoryEntity.ts b/src/application/Repository/RepositoryEntity.ts new file mode 100644 index 00000000..1603cd97 --- /dev/null +++ b/src/application/Repository/RepositoryEntity.ts @@ -0,0 +1,6 @@ +/** Aggregate root */ +export type RepositoryEntityId = string; + +export interface RepositoryEntity { + readonly id: RepositoryEntityId; +} diff --git a/src/application/collections/windows.yaml b/src/application/collections/windows.yaml index b1d7bf12..d582f2f5 100644 --- a/src/application/collections/windows.yaml +++ b/src/application/collections/windows.yaml @@ -3207,7 +3207,7 @@ actions: parameters: appCapability: bluetoothSync - - category: Disable app access to voice activation + category: Disable app voice activation docs: |- # refactor-with-variable: Same • App Access Caution This category safeguards against unauthorized app activation via voice commands. @@ -15671,7 +15671,7 @@ actions: data: '1' 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: - name: Minimize CPU usage during scans @@ -15866,7 +15866,7 @@ actions: category: Disable scanning archive files children: - - name: Disable scanning archive files + name: Disable Defender archive file scanning docs: - https://admx.help/?Category=Windows_10_2016&Policy=Microsoft.Policies.WindowsDefender::Scan_DisableArchiveScanning # Managing with MpPreference module: diff --git a/src/domain/Application.ts b/src/domain/Application.ts index 2fc1230d..77e85796 100644 --- a/src/domain/Application.ts +++ b/src/domain/Application.ts @@ -1,6 +1,6 @@ import { OperatingSystem } from './OperatingSystem'; import type { IApplication } from './IApplication'; -import type { ICategoryCollection } from './ICategoryCollection'; +import type { ICategoryCollection } from './Collection/ICategoryCollection'; import type { ProjectDetails } from './Project/ProjectDetails'; export class Application implements IApplication { diff --git a/src/domain/CategoryCollection.ts b/src/domain/Collection/CategoryCollection.ts similarity index 54% rename from src/domain/CategoryCollection.ts rename to src/domain/Collection/CategoryCollection.ts index 71d8c61e..36f18187 100644 --- a/src/domain/CategoryCollection.ts +++ b/src/domain/Collection/CategoryCollection.ts @@ -1,11 +1,13 @@ import { getEnumValues, assertInRange } from '@/application/Common/Enum'; -import { RecommendationLevel } from './Executables/Script/RecommendationLevel'; -import { OperatingSystem } from './OperatingSystem'; -import type { IEntity } from '../infrastructure/Entity/IEntity'; -import type { Category } from './Executables/Category/Category'; -import type { Script } from './Executables/Script/Script'; -import type { IScriptingDefinition } from './IScriptingDefinition'; +import { RecommendationLevel } from '../Executables/Script/RecommendationLevel'; +import { OperatingSystem } from '../OperatingSystem'; +import { validateCategoryCollection } from './Validation/CompositeCategoryCollectionValidator'; +import type { ExecutableId } from '../Executables/Identifiable'; +import type { Category } from '../Executables/Category/Category'; +import type { Script } from '../Executables/Script/Script'; +import type { IScriptingDefinition } from '../IScriptingDefinition'; import type { ICategoryCollection } from './ICategoryCollection'; +import type { CategoryCollectionValidator } from './Validation/CategoryCollectionValidator'; export class CategoryCollection implements ICategoryCollection { public readonly os: OperatingSystem; @@ -22,22 +24,24 @@ export class CategoryCollection implements ICategoryCollection { constructor( parameters: CategoryCollectionInitParameters, + validate: CategoryCollectionValidator = validateCategoryCollection, ) { this.os = parameters.os; this.actions = parameters.actions; this.scripting = parameters.scripting; this.queryable = makeQueryable(this.actions); - assertInRange(this.os, OperatingSystem); - ensureValid(this.queryable); - ensureNoDuplicates(this.queryable.allCategories); - ensureNoDuplicates(this.queryable.allScripts); + validate({ + allScripts: this.queryable.allScripts, + allCategories: this.queryable.allCategories, + operatingSystem: this.os, + }); } - public getCategory(categoryId: number): Category { - const category = this.queryable.allCategories.find((c) => c.id === categoryId); + public getCategory(executableId: ExecutableId): Category { + const category = this.queryable.allCategories.find((c) => c.executableId === executableId); if (!category) { - throw new Error(`Missing category with ID: "${categoryId}"`); + throw new Error(`Missing category with ID: "${executableId}"`); } return category; } @@ -48,10 +52,10 @@ export class CategoryCollection implements ICategoryCollection { return scripts ?? []; } - public getScript(scriptId: string): Script { - const script = this.queryable.allScripts.find((s) => s.id === scriptId); + public getScript(executableId: ExecutableId): Script { + const script = this.queryable.allScripts.find((s) => s.executableId === executableId); if (!script) { - throw new Error(`missing script: ${scriptId}`); + throw new Error(`Missing script: ${executableId}`); } return script; } @@ -65,21 +69,6 @@ export class CategoryCollection implements ICategoryCollection { } } -function ensureNoDuplicates(entities: ReadonlyArray>) { - 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 { readonly os: OperatingSystem; readonly actions: ReadonlyArray; @@ -92,35 +81,12 @@ interface QueryableCollection { readonly scriptsByLevel: Map; } -function ensureValid(application: QueryableCollection) { - 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( +function flattenCategoryHierarchy( categories: ReadonlyArray, ): [Category[], Script[]] { const [subCategories, subScripts] = (categories || []) // Parse children - .map((category) => flattenApplication(category.subCategories)) + .map((category) => flattenCategoryHierarchy(category.subcategories)) // Flatten results .reduce(([previousCategories, previousScripts], [currentCategories, currentScripts]) => { return [ @@ -143,7 +109,7 @@ function flattenApplication( function makeQueryable( actions: ReadonlyArray, ): QueryableCollection { - const flattened = flattenApplication(actions); + const flattened = flattenCategoryHierarchy(actions); return { allCategories: flattened[0], allScripts: flattened[1], diff --git a/src/domain/ICategoryCollection.ts b/src/domain/Collection/ICategoryCollection.ts similarity index 82% rename from src/domain/ICategoryCollection.ts rename to src/domain/Collection/ICategoryCollection.ts index c7b7d799..ac7879fc 100644 --- a/src/domain/ICategoryCollection.ts +++ b/src/domain/Collection/ICategoryCollection.ts @@ -3,6 +3,7 @@ import { OperatingSystem } from '@/domain/OperatingSystem'; import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel'; import type { Script } from '@/domain/Executables/Script/Script'; import type { Category } from '@/domain/Executables/Category/Category'; +import type { ExecutableId } from '../Executables/Identifiable'; export interface ICategoryCollection { readonly scripting: IScriptingDefinition; @@ -12,8 +13,8 @@ export interface ICategoryCollection { readonly actions: ReadonlyArray; getScriptsByLevel(level: RecommendationLevel): ReadonlyArray