Compare commits

...

6 Commits

Author SHA1 Message Date
undergroundwires
58f902216b Fix disabling of Microsoft Defender $170
- Change naming from Windows Defender to Microsoft Defender to match
  latest branding.
- Add more extensive documentation.
- Add more scripts extending ways to disable Defender.
- Disable "Windows Security Center Service"
- Add missing `SetMpPreference` commands
- New disabling:
  - Disabling of Windows features related to Defender.
  - Disable Antimalware Scan Interface (AMSI)

TODO: Soft delete Defender directories, like
`$env:programdata\Microsoft\Windows Defender`

TODO: Add from here: https://learn.microsoft.com/en-us/mem/intune/protect/antivirus-security-experience-windows-settings

New scripts:

- Disable "Windows Security Center" service
- Kill SmartScreen process
- Disable "Microsoft Security Core Boot" service

Improved scripts:

- Disable Intrusion Prevention System (IPS): Add CLI command to disable
  it.

TODO: These to separate commit

TODO:

- Improve disabling of `RenameSystemFile` AsTrustedInstaller and get
  back all commented out code.
2024-07-18 09:48:06 +02:00
undergroundwires
48d6dbd700 Refactor to use string IDs for executables #262
This commit unifies the concepts of executables having same ID
structure. It paves the way for more complex ID structure and using IDs
in collection files as part of new ID solution (#262). Using string IDs
also leads to more expressive test code.

This commit also refactors the rest of the code to adopt to the changes.

This commit:

- Separate concerns from entities for data access (in repositories) and
  executables. Executables use `Identifiable` meanwhile repositories use
  `RepositoryEntity`.
- Refactor unnecessary generic parameters for enttities and ids,
  enforcing string gtype everwyhere.
- Changes numeric IDs to string IDs for categories to unify the
  retrieval and construction for executables, using pseudo-ids (their
  names) just like scripts.
- Remove `BaseEntity` for simplicity.
- Simplify usage and construction of executable objects.
  Move factories responsible for creation of category/scripts to domain
  layer. Do not longer export `CollectionCategorY` and
  `CollectionScript`.
- Use named typed for string IDs for better differentation of different
  ID contexts in code.
2024-07-08 23:23:05 +02:00
undergroundwires
19ea8dbc5b Fix close button overlap by scrollbar
This commit positions the close button on modaals to remain visible on
small screens.

This fix addresses an issue where the dialog's close button was being
obscured by the vertical scrollbar on narrow screens. This problem was
particularly evident in Chromium browsers, which display a larger
scrollbar.

This change includes a new mixin to document the purpose. The mixin sets
the position of close button to `absolute` and aligns it to the top
right. This way, the button stays visible and accessible regaRdless of
The scrollbar's presence, thereby improving the user inTerfaCE usability
on deviceS with smaller screens.
2024-07-08 12:16:51 +02:00
undergroundwires
70959ccada Fix documentation button spacing on small screens
The previous layout lacked a specified gap between the node header and
the documentation toggle button in the tree view. This resulted in a
crowded appearance, making the interface look cluttered and reducing
readability, especially on smaller screens.

This commit introduces a relative gap, adjusting the spacing based on
the text size. This change enhances the visual separation and improves
user interaction by ensuring the documentation button and text do not
overlap, regardless of screen size.
2024-07-07 11:59:27 +02:00
undergroundwires
5d365f65fa win: improve service disabling as TrustedInstaller
This commit changes the mechanism to disable services using
TrustedInstaller privileges, improving consistency and flexibility.

Key changes:

- Introduce `DisableServiceInRegistryAsTrustedInstaller` as a shared
  function to standardize the disabling process. This function aligns
  with existing ones to facilitate easier testing and method switching.
- Update the revert logic to avoid unnecessary service restarts when
  they are manually started.
- Enhance readability with added comments in generated code sections.
- Improve documentation for `DisableService` and
  `DisableServiceInRegistry` to reflect new functionalities.
- Support multiline code in `RunInlineCodeAsTrustedInstaller` for
  complex scenarios.

Other supporting changes:

- Remove redundant TrustedInstaller privileges in the `Sense` service
  disabling.
- Document default service statuses to inform about service behaviors
  across different Windows versions.
2024-07-06 12:28:42 +02:00
undergroundwires-bot
cca397c8c7 ⬆️ bump everywhere to 0.13.5 2024-06-27 08:13:03 +00:00
104 changed files with 2152 additions and 1231 deletions

View File

@@ -1,5 +1,32 @@
# Changelog # Changelog
## 0.13.5 (2024-06-26)
* ci/cd: centralize and bump artifact uploads | [22d6c79](https://github.com/undergroundwires/privacy.sexy/commit/22d6c7991eb2c138578a7d41950f301906dbf703)
* win: document and improve Firefox telemetry #259 | [8341411](https://github.com/undergroundwires/privacy.sexy/commit/8341411be434c6d145e942b1792020ccf02f58c8)
* Add image to `README.md` to thank supporters | [fa2a92b](https://github.com/undergroundwires/privacy.sexy/commit/fa2a92bf893933bf5cd04512a712b7aa1b921277)
* win: improve executable blocking, Chrome reporting | [f21ef92](https://github.com/undergroundwires/privacy.sexy/commit/f21ef9250a2f459dbd4f789d857c78298fc202e6)
* mac: discourage and document captive portal script | [b29cd7b](https://github.com/undergroundwires/privacy.sexy/commit/b29cd7b5f74accf92c9700c3171670f82c8cb3b3)
* win: fix revert scripts for removing shortcuts | [8becc7d](https://github.com/undergroundwires/privacy.sexy/commit/8becc7dbc46af4441900e9841a716a53735bc82e)
* Refactor to unify scripts/categories as Executable | [c138f74](https://github.com/undergroundwires/privacy.sexy/commit/c138f74460bafaba3da55a65f3942bb6f95b1d99)
* Add object property validation in parser #369 | [6ecfa9b](https://github.com/undergroundwires/privacy.sexy/commit/6ecfa9b954edc10401acaf5c735eec0fc9f991cd)
* win: fix missing app access recommendations #369 | [1c2d82d](https://github.com/undergroundwires/privacy.sexy/commit/1c2d82dc9bd412ea601ab2550ba0b4f7d144f8e8)
* win: fix text and handwriting script omission #369 | [1a10cf2](https://github.com/undergroundwires/privacy.sexy/commit/1a10cf2e5f87cd8eb421ef77f6ce764b5482515e)
* mac: document, improve, encourage clearing logs | [e9a5285](https://github.com/undergroundwires/privacy.sexy/commit/e9a52859f63609c3f56def0b3e4d1ac6e5661536)
* Add schema validation for collection files #369 | [dc03bff](https://github.com/undergroundwires/privacy.sexy/commit/dc03bff324d673101002bb16f14e0429e8170fbb)
* win: fix incomplete VSCEIP, location scripts | [48761f6](https://github.com/undergroundwires/privacy.sexy/commit/48761f62a242f0910307994271cbe6730fb30f7e)
* Add type validation for parameters and fix types | [fac26a6](https://github.com/undergroundwires/privacy.sexy/commit/fac26a6ca07479c84fe62c5ea2a572dad1898ef8)
* Bump Electron to latest | [ed93614](https://github.com/undergroundwires/privacy.sexy/commit/ed93614ca34b1ab166e645cc5bedd497b0caeaac)
* Trim compiler error output for better readability | [78c62cf](https://github.com/undergroundwires/privacy.sexy/commit/78c62cfc953dbba543d8bdc42828a4ef4b13a7c7)
* win: fix errors due to missing Edge uninstaller | [2f82873](https://github.com/undergroundwires/privacy.sexy/commit/2f828735a87f98ba87b4fc826823d1482d4f2db2)
* win: fix latest Edge removal on Windows 10 #309 | [e7031a3](https://github.com/undergroundwires/privacy.sexy/commit/e7031a3ae4e57b6522c6ca67fc30e8a8718506b2)
* win: categorize, rename, doc Chrome & Edge scripts | [f286f92](https://github.com/undergroundwires/privacy.sexy/commit/f286f92b1fec49e89eea8982dffbc3d6ef1defde)
* win: add disabling Edge/WebView2 auto-updates #309 | [ed7e69c](https://github.com/undergroundwires/privacy.sexy/commit/ed7e69c07efe83fdb7f4af13aa220ff991fbbe59)
* win, linux, mac: fix typos #373 | [c09c5ff](https://github.com/undergroundwires/privacy.sexy/commit/c09c5ffa47865f7c76910644558b6783ed44f1e4)
* win: add more Edge scripts including AI & ads | [1430d52](https://github.com/undergroundwires/privacy.sexy/commit/1430d5215ab094d8201710761d631dc2bd740918)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.13.4...0.13.5)
## 0.13.4 (2024-05-27) ## 0.13.4 (2024-05-27)
* Add specific empty function name compiler error | [870120b](https://github.com/undergroundwires/privacy.sexy/commit/870120bc13909a3681e0f0a2351806849476342f) * Add specific empty function name compiler error | [870120b](https://github.com/undergroundwires/privacy.sexy/commit/870120bc13909a3681e0f0a2351806849476342f)

View File

@@ -122,7 +122,7 @@
## Get started ## Get started
- 🌍️ **Online**: [https://privacy.sexy](https://privacy.sexy). - 🌍️ **Online**: [https://privacy.sexy](https://privacy.sexy).
- 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.4/privacy.sexy-Setup-0.13.4.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.4/privacy.sexy-0.13.4.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.4/privacy.sexy-0.13.4.AppImage). For more options, see [here](#additional-install-options). - 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.5/privacy.sexy-Setup-0.13.5.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.5/privacy.sexy-0.13.5.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.5/privacy.sexy-0.13.5.AppImage). For more options, see [here](#additional-install-options).
See also: See also:

View File

@@ -30,6 +30,8 @@ Related documentation:
### Executables ### Executables
They represent independently executable tweaks 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,

2
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{ {
"name": "privacy.sexy", "name": "privacy.sexy",
"version": "0.13.4", "version": "0.13.5",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "privacy.sexy", "name": "privacy.sexy",
"version": "0.13.4", "version": "0.13.5",
"private": true, "private": true,
"slogan": "Privacy is sexy", "slogan": "Privacy is sexy",
"description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy.", "description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy.",

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

@@ -36,12 +36,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: string): 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,7 +2,7 @@ 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 { UserSelectedScript } from './UserSelectedScript'; import { UserSelectedScript } from './UserSelectedScript';
import type { ScriptSelection } from './ScriptSelection'; import type { ScriptSelection } from './ScriptSelection';
@@ -16,7 +16,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 +25,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);
} }
@@ -49,7 +49,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,9 +116,9 @@ 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: string, revert: boolean): number {
@@ -152,24 +152,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,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, // arbitrary ID
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';
@@ -10,6 +9,7 @@ import type { ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptC
import { createScriptCode } from '@/domain/Executables/Script/Code/ScriptCodeFactory'; import { createScriptCode } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
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 { 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';
@@ -37,6 +37,7 @@ export const parseScript: ScriptParser = (
validateScript(data, validator); validateScript(data, validator);
try { try {
const script = scriptUtilities.createScript({ const script = scriptUtilities.createScript({
executableId: data.name, // arbitrary ID
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;
}

File diff suppressed because it is too large Load Diff

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,10 +1,10 @@
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 type { ExecutableId, Identifiable } from '../Executables/Identifiable';
import type { Category } from './Executables/Category/Category'; import type { Category } from '../Executables/Category/Category';
import type { Script } from './Executables/Script/Script'; import type { Script } from '../Executables/Script/Script';
import type { IScriptingDefinition } from './IScriptingDefinition'; import type { IScriptingDefinition } from '../IScriptingDefinition';
import type { ICategoryCollection } from './ICategoryCollection'; import type { ICategoryCollection } from './ICategoryCollection';
export class CategoryCollection implements ICategoryCollection { export class CategoryCollection implements ICategoryCollection {
@@ -30,14 +30,14 @@ export class CategoryCollection implements ICategoryCollection {
this.queryable = makeQueryable(this.actions); this.queryable = makeQueryable(this.actions);
assertInRange(this.os, OperatingSystem); assertInRange(this.os, OperatingSystem);
ensureValid(this.queryable); ensureValid(this.queryable);
ensureNoDuplicates(this.queryable.allCategories); ensureNoDuplicateIds(this.queryable.allCategories);
ensureNoDuplicates(this.queryable.allScripts); ensureNoDuplicateIds(this.queryable.allScripts);
} }
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 +48,10 @@ export class CategoryCollection implements ICategoryCollection {
return scripts ?? []; return scripts ?? [];
} }
public getScript(scriptId: string): Script { public getScript(executableId: string): 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,17 +65,14 @@ export class CategoryCollection implements ICategoryCollection {
} }
} }
function ensureNoDuplicates<TKey>(entities: ReadonlyArray<IEntity<TKey>>) { function ensureNoDuplicateIds(executables: ReadonlyArray<Identifiable>) { // TODO: Unit test this
const isUniqueInArray = (id: TKey, index: number, array: readonly TKey[]) => array const duplicatedIds = executables
.findIndex((otherId) => otherId === id) !== index; .map((e) => e.executableId)
const duplicatedIds = entities .filter((id, index, array) => array.findIndex((otherId) => otherId === id) !== index);
.map((entity) => entity.id)
.filter((id, index, array) => !isUniqueInArray(id, index, array))
.filter(isUniqueInArray);
if (duplicatedIds.length > 0) { if (duplicatedIds.length > 0) {
const duplicatedIdsText = duplicatedIds.map((id) => `"${id}"`).join(','); const duplicatedIdsText = duplicatedIds.map((id) => `"${id}"`).join(',');
throw new Error( throw new Error(
`Duplicate entities are detected with following id(s): ${duplicatedIdsText}`, `Duplicate executables are detected with following id(s): ${duplicatedIdsText}`,
); );
} }
} }
@@ -120,7 +117,7 @@ function flattenApplication(
): [Category[], Script[]] { ): [Category[], Script[]] {
const [subCategories, subScripts] = (categories || []) const [subCategories, subScripts] = (categories || [])
// Parse children // Parse children
.map((category) => flattenApplication(category.subCategories)) .map((category) => flattenApplication(category.subcategories))
// Flatten results // Flatten results
.reduce(([previousCategories, previousScripts], [currentCategories, currentScripts]) => { .reduce(([previousCategories, previousScripts], [currentCategories, currentScripts]) => {
return [ return [

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

@@ -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,26 @@
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';
export interface ScriptInitParameters {
readonly executableId: string;
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: string;
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 +30,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 +43,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 } 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: string): 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: string): 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: string): 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';
@@ -142,3 +142,4 @@ export default defineComponent({
}, },
}); });
</script> </script>
@/domain/Collection/ICategoryCollection

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,
}, },
}, },
@@ -60,3 +61,4 @@ export default defineComponent({
font-size: $font-size-absolute-normal; font-size: $font-size-absolute-normal;
} }
</style> </style>
@/domain/Collection/ICategoryCollection

View File

@@ -72,6 +72,7 @@ export default defineComponent({
.header { .header {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: $spacing-relative-small; // Adjusts spacing between documentation button and adjacent text to prevent visual crowding.
.content { .content {
flex: 1; // Expands the content to fill available width, aligning the documentation button to the right. flex: 1; // Expands the content to fill available width, aligning the documentation button to the right.
} }

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 id: 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';
@@ -64,3 +64,4 @@ export default defineComponent({
}, },
}); });
</script> </script>
@/domain/Collection/ICategoryCollection

View File

@@ -1,17 +1,18 @@
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';
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: string, collection: ICategoryCollection) {
this.categoryId = getCategoryId(nodeId); this.categoryId = createExecutableIdFromNodeId(nodeId);
this.scriptReverters = createScriptReverters(this.categoryId, collection); this.scriptReverters = createScriptReverters(this.categoryId, collection);
} }
@@ -37,12 +38,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,4 +1,4 @@
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';

View File

@@ -1,13 +1,13 @@
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 { createExecutableIdFromNodeId } from '../../TreeViewAdapter/CategoryNodeMetadataConverter';
import type { Reverter } from './Reverter'; import type { Reverter } from './Reverter';
export class ScriptReverter implements Reverter { export class ScriptReverter implements Reverter {
private readonly scriptId: string; private readonly scriptId: string;
constructor(nodeId: string) { constructor(nodeId: string) {
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

@@ -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), id: 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), id: createNodeIdForExecutable(script),
type: NodeType.Script, type: NodeType.Script,
text: script.name, text: script.name,
children: [], children: [],

View File

@@ -0,0 +1,3 @@
export function UseExecutableFromTreeNodeId(treeNodeId: string) {
}

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

@@ -82,7 +82,14 @@ export default defineComponent({
width: auto; width: auto;
font-size: $font-size-absolute-large; font-size: $font-size-absolute-large;
margin-right: $spacing-absolute-small; margin-right: $spacing-absolute-small;
align-self: flex-start;
@mixin keep-visible-above-scrollbar { // Prevents close button from being obscured by scrollbar on small screens.
position: absolute;
top: 0;
right: 0; // Aligns right
}
@include keep-visible-above-scrollbar;
} }
} }
</style> </style>

16
test.ps1 Normal file
View File

@@ -0,0 +1,16 @@
# (Command only avalable in Windows Server)
# name: Uninstall Windows Defender from Windows Server
# docs: https://web.archive.org/web/20210926064024/https://docs.microsoft.com/en-us/microsoft-365/security/defender-endpoint/microsoft-defender-antivirus-on-windows-server?view=o365-worldwide
# Do
Uninstall-WindowsFeature -Name Windows-Defender
Uninstall-WindowsFeature -Name Windows-Defender-GUI
# Revert:
Install-WindowsFeature -Name Windows-Defender
Install-WindowsFeature -Name Windows-Defender-GUI

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

@@ -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-id')])
.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-id')])
.withCategoryMatches([new CategoryStub(5)]) .withCategoryMatches([new CategoryStub('matched-category-id')])
.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-id`),
];
private categoryMatches: readonly Category[] = [new CategoryStub(5)]; private categoryMatches: readonly Category[] = [
new CategoryStub(`[${ResultBuilder.name}]matched-category-id`),
];
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);

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,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[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 +313,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[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 +326,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 +339,7 @@ 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 +353,7 @@ 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 +367,9 @@ 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[0].executableId, newStatus: { isSelected: true, isReverted: false } },
{ scriptId: allScripts[1].id, newStatus: { isSelected: true, isReverted: true } }, { scriptId: allScripts[1].executableId, newStatus: { isSelected: true, isReverted: true } },
{ scriptId: allScripts[2].id, newStatus: { isSelected: false } }, { scriptId: allScripts[2].executableId, newStatus: { isSelected: false } },
], ],
getExpectedFinalSelection: (allScripts) => [ getExpectedFinalSelection: (allScripts) => [
allScripts[0].toSelectedScript().withRevert(false), allScripts[0].toSelectedScript().withRevert(false),
@@ -408,7 +408,7 @@ 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 +416,15 @@ 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[0].executableId, newStatus: { isSelected: true, isReverted: false } },
{ scriptId: allScripts[1].id, newStatus: { isSelected: false } }, { scriptId: allScripts[1].executableId, newStatus: { isSelected: false } },
], ],
}, },
]; ];
@@ -459,7 +459,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 +481,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 +502,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 +525,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 } },
], ],
}); });
} }
@@ -572,7 +572,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

@@ -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 { indentText } from '@tests/shared/Text'; import { indentText } from '@tests/shared/Text';
import type { NonEmptyCollectionAssertion, ObjectAssertion } from '@/application/Parser/Common/TypeValidator'; import type { NonEmptyCollectionAssertion, ObjectAssertion } from '@/application/Parser/Common/TypeValidator';
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,178 +305,169 @@ describe('CategoryParser', () => {
}); });
}); });
}); });
}); describe('parses correct subscript', () => {
describe('rethrows exception if category factory fails', () => { it('parses single script correctly', () => {
// arrange // arrange
const givenData = new CategoryDataStub(); const expectedScript = new ScriptStub('expected script');
const expectedContextMessage = 'Failed to parse category.'; const scriptParser = new ScriptParserStub();
const expectedError = new Error(); const childScriptData = createScriptDataWithCode();
// act & assert const categoryData = new CategoryDataStub()
itThrowsContextualError({ .withChildren([childScriptData]);
throwingAction: (wrapError) => { scriptParser.setupParsedResultForData(childScriptData, expectedScript);
const validatorStub = new ExecutableValidatorStub(); const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy();
validatorStub.createContextualErrorMessage = (message) => message; // act
const factoryMock: CategoryFactory = () => { const actualCategory = new TestContext()
throw expectedError; .withData(categoryData)
}; .withScriptParser(scriptParser.get())
new TestBuilder() .withCategoryFactory(categoryFactorySpy)
.withCategoryFactory(factoryMock)
.withValidatorFactory(() => validatorStub)
.withErrorWrapper(wrapError)
.withData(givenData)
.parseCategory(); .parseCategory();
}, // assert
expectedWrappedError: expectedError, const actualScripts = getInitParameters(actualCategory)?.scripts;
expectedContextMessage, expectExists(actualScripts);
}); expect(actualScripts).to.have.lengthOf(1);
}); const actualScript = actualScripts[0];
it('parses docs correctly', () => { expect(actualScript).to.equal(expectedScript);
// arrange });
const url = 'https://privacy.sexy'; it('parses multiple scripts correctly', () => {
const categoryData = new CategoryDataStub() // arrange
.withDocs(url); const expectedScripts = [
const parseDocs: DocsParser = (data) => { new ScriptStub('expected-first-script'),
return [ new ScriptStub('expected-second-script'),
`parsed docs: ${JSON.stringify(data)}`, ];
]; const childrenData = [
}; createScriptDataWithCall(),
const expectedDocs = parseDocs(categoryData); createScriptDataWithCode(),
const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy(); ];
// act const scriptParser = new ScriptParserStub();
const actualCategory = new TestBuilder() childrenData.forEach((_, index) => {
.withData(categoryData) scriptParser.setupParsedResultForData(childrenData[index], expectedScripts[index]);
.withCategoryFactory(categoryFactorySpy) });
.withDocsParser(parseDocs) const categoryData = new CategoryDataStub()
.parseCategory(); .withChildren(childrenData);
// assert const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy();
const actualDocs = getInitParameters(actualCategory)?.docs; // act
expect(actualDocs).to.deep.equal(expectedDocs); const actualCategory = new TestContext()
}); .withScriptParser(scriptParser.get())
describe('parses expected subscript', () => { .withData(categoryData)
it('parses single script correctly', () => { .withCategoryFactory(categoryFactorySpy)
// arrange .parseCategory();
const expectedScript = new ScriptStub('expected script'); // assert
const scriptParser = new ScriptParserStub(); const actualParsedScripts = getInitParameters(actualCategory)?.scripts;
const childScriptData = createScriptDataWithCode(); expectExists(actualParsedScripts);
const categoryData = new CategoryDataStub() expect(actualParsedScripts.length).to.equal(expectedScripts.length);
.withChildren([childScriptData]); expect(actualParsedScripts).to.have.members(expectedScripts);
scriptParser.setupParsedResultForData(childScriptData, expectedScript); });
const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy(); it('parses all scripts with correct utilities', () => {
// act // arrange
const actualCategory = new TestBuilder() const expected = new CategoryCollectionSpecificUtilitiesStub();
.withData(categoryData) const scriptParser = new ScriptParserStub();
.withScriptParser(scriptParser.get()) const childrenData = [
.withCategoryFactory(categoryFactorySpy) createScriptDataWithCode(),
.parseCategory(); createScriptDataWithCode(),
// assert createScriptDataWithCode(),
const actualScripts = getInitParameters(actualCategory)?.scripts; ];
expectExists(actualScripts); const categoryData = new CategoryDataStub()
expect(actualScripts).to.have.lengthOf(1); .withChildren(childrenData);
const actualScript = actualScripts[0]; const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy();
expect(actualScript).to.equal(expectedScript); // act
}); const actualCategory = new TestContext()
it('parses multiple scripts correctly', () => { .withData(categoryData)
// arrange .withCollectionUtilities(expected)
const expectedScripts = [ .withScriptParser(scriptParser.get())
new ScriptStub('expected-first-script'), .withCategoryFactory(categoryFactorySpy)
new ScriptStub('expected-second-script'), .parseCategory();
]; // assert
const childrenData = [ const actualParsedScripts = getInitParameters(actualCategory)?.scripts;
createScriptDataWithCall(), expectExists(actualParsedScripts);
createScriptDataWithCode(), const actualUtilities = actualParsedScripts.map(
]; (s) => scriptParser.getParseParameters(s)[1],
const scriptParser = new ScriptParserStub(); );
childrenData.forEach((_, index) => { expect(
scriptParser.setupParsedResultForData(childrenData[index], expectedScripts[index]); actualUtilities.every(
(actual) => actual === expected,
),
formatAssertionMessage([
`Expected all elements to be ${JSON.stringify(expected)}`,
'All elements:',
indentText(JSON.stringify(actualUtilities)),
]),
).to.equal(true);
}); });
const categoryData = new CategoryDataStub()
.withChildren(childrenData);
const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy();
// act
const actualCategory = new TestBuilder()
.withScriptParser(scriptParser.get())
.withData(categoryData)
.withCategoryFactory(categoryFactorySpy)
.parseCategory();
// assert
const actualParsedScripts = getInitParameters(actualCategory)?.scripts;
expectExists(actualParsedScripts);
expect(actualParsedScripts.length).to.equal(expectedScripts.length);
expect(actualParsedScripts).to.have.members(expectedScripts);
}); });
it('parses all scripts with correct utilities', () => { it('parses correct subcategories', () => {
// arrange // arrange
const expected = new CategoryCollectionSpecificUtilitiesStub(); const expectedChildCategory = new CategoryStub('expected-child-category');
const scriptParser = new ScriptParserStub(); const childCategoryData = new CategoryDataStub()
const childrenData = [ .withName('expected child category')
createScriptDataWithCode(), .withChildren([createScriptDataWithCode()]);
createScriptDataWithCode(),
createScriptDataWithCode(),
];
const categoryData = new CategoryDataStub() const categoryData = new CategoryDataStub()
.withChildren(childrenData); .withName('category name')
.withChildren([childCategoryData]);
const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy(); const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy();
// act // act
const actualCategory = new TestBuilder() const actualCategory = new TestContext()
.withData(categoryData) .withData(categoryData)
.withCollectionUtilities(expected) .withCategoryFactory((parameters) => {
.withScriptParser(scriptParser.get()) if (parameters.name === childCategoryData.category) {
.withCategoryFactory(categoryFactorySpy) return expectedChildCategory;
}
return categoryFactorySpy(parameters);
})
.parseCategory(); .parseCategory();
// assert // assert
const actualParsedScripts = getInitParameters(actualCategory)?.scripts; const actualSubcategories = getInitParameters(actualCategory)?.subcategories;
expectExists(actualParsedScripts); expectExists(actualSubcategories);
const actualUtilities = actualParsedScripts.map( expect(actualSubcategories).to.have.lengthOf(1);
(s) => scriptParser.getParseParameters(s)[1], expect(actualSubcategories[0]).to.equal(expectedChildCategory);
);
expect(
actualUtilities.every(
(actual) => actual === expected,
),
formatAssertionMessage([
`Expected all elements to be ${JSON.stringify(expected)}`,
'All elements:',
indentText(JSON.stringify(actualUtilities)),
]),
).to.equal(true);
}); });
}); });
it('returns expected subcategories', () => { describe('category creation', () => {
// arrange it('creates category from the factory', () => {
const expectedChildCategory = new CategoryStub(33); // arrange
const childCategoryData = new CategoryDataStub() const expectedCategory = new CategoryStub('expected-category');
.withName('expected child category') const categoryFactory: CategoryFactory = () => expectedCategory;
.withChildren([createScriptDataWithCode()]); // act
const categoryData = new CategoryDataStub() const actualCategory = new TestContext()
.withName('category name') .withCategoryFactory(categoryFactory)
.withChildren([childCategoryData]); .parseCategory();
const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy(); // assert
// act expect(actualCategory).to.equal(expectedCategory);
const actualCategory = new TestBuilder() });
.withData(categoryData) describe('rethrows exception if category factory fails', () => {
.withCategoryFactory((parameters) => { // arrange
if (parameters.name === childCategoryData.category) { const givenData = new CategoryDataStub();
return expectedChildCategory; const expectedContextMessage = 'Failed to parse category.';
} const expectedError = new Error();
return categoryFactorySpy(parameters); // act & assert
}) itThrowsContextualError({
.parseCategory(); throwingAction: (wrapError) => {
// assert const validatorStub = new ExecutableValidatorStub();
const actualSubcategories = getInitParameters(actualCategory)?.subcategories; validatorStub.createContextualErrorMessage = (message) => message;
expectExists(actualSubcategories); const factoryMock: CategoryFactory = () => {
expect(actualSubcategories).to.have.lengthOf(1); throw expectedError;
expect(actualSubcategories[0]).to.equal(expectedChildCategory); };
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

@@ -29,53 +29,206 @@ 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 { 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', () => {
it('parses name correctly', () => { describe('property validation', () => {
// arrange describe('validates object', () => {
const expected = 'test-expected-name'; // arrange
const scriptData = createScriptDataWithCode() const expectedScript = createScriptDataWithCall();
.withName(expected); const expectedContext: ScriptErrorContext = {
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy(); type: ExecutableType.Script,
// act self: expectedScript,
const actualScript = new TestContext() };
.withData(scriptData) const expectedAssertion: ObjectAssertion<CallScriptData & CodeScriptData> = {
.withScriptFactory(scriptFactorySpy) value: expectedScript,
.parseScript(); valueName: expectedScript.name,
// assert allowedProperties: [
const actualName = getInitParameters(actualScript)?.name; 'name', 'recommend', 'code', 'revertCode', 'call', 'docs',
expect(actualName).to.equal(expected); ],
};
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,
};
},
});
});
});
});
}); });
it('parses docs correctly', () => { describe('id', () => {
// arrange it('creates ID correctly', () => {
const expectedDocs = ['https://expected-doc1.com', 'https://expected-doc2.com']; // arrange
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy(); const expectedId: ExecutableId = 'expected-id';
const scriptData = createScriptDataWithCode() const scriptData = createScriptDataWithCode()
.withDocs(expectedDocs); .withName(expectedId);
const docsParser: DocsParser = (data) => data.docs as typeof expectedDocs; const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
// act // act
const actualScript = new TestContext() const actualScript = new TestContext()
.withData(scriptData) .withData(scriptData)
.withScriptFactory(scriptFactorySpy) .withScriptFactory(scriptFactorySpy)
.withDocsParser(docsParser) .parseScript();
.parseScript(); // assert
// assert const actualId = getInitParameters(actualScript)?.executableId;
const actualDocs = getInitParameters(actualScript)?.docs; expect(actualId).to.equal(expectedId);
expect(actualDocs).to.deep.equal(expectedDocs); });
}); });
it('gets script from the factory', () => { describe('name', () => {
// arrange it('parses name correctly', () => {
const expectedScript = new ScriptStub('expected-script'); // arrange
const scriptFactory: ScriptFactory = () => expectedScript; const expected = 'test-expected-name';
// act const scriptData = createScriptDataWithCode()
const actualScript = new TestContext() .withName(expected);
.withScriptFactory(scriptFactory) const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
.parseScript(); // act
// assert const actualScript = new TestContext()
expect(actualScript).to.equal(expectedScript); .withData(scriptData)
.withScriptFactory(scriptFactorySpy)
.parseScript();
// assert
const actualName = getInitParameters(actualScript)?.name;
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', () => {
// arrange
const expectedDocs = ['https://expected-doc1.com', 'https://expected-doc2.com'];
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
const scriptData = createScriptDataWithCode()
.withDocs(expectedDocs);
const docsParser: DocsParser = (data) => data.docs as typeof expectedDocs;
// act
const actualScript = new TestContext()
.withData(scriptData)
.withScriptFactory(scriptFactorySpy)
.withDocsParser(docsParser)
.parseScript();
// assert
const actualDocs = getInitParameters(actualScript)?.docs;
expect(actualDocs).to.deep.equal(expectedDocs);
});
}); });
describe('level', () => { describe('level', () => {
describe('generated `undefined` level if given absent value', () => { describe('generated `undefined` level if given absent value', () => {
@@ -261,175 +414,46 @@ 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); // act
const expectedContext: ScriptErrorContext = { const actualScript = new TestContext()
type: ExecutableType.Script, .withScriptFactory(scriptFactory)
self: script, .parseScript();
}; // assert
itValidatesName((validatorFactory) => { expect(actualScript).to.equal(expectedScript);
// act
new TestContext()
.withData(script)
.withValidatorFactory(validatorFactory)
.parseScript();
// assert
return {
expectedNameToValidate: expectedName,
expectedErrorContext: expectedContext,
};
});
}); });
describe('validates for defined data', () => { describe('rethrows exception if script factory fails', () => {
// arrange // arrange
const expectedScript = createScriptDataWithCall(); const givenData = createScriptDataWithCode();
const expectedContext: ScriptErrorContext = { const expectedContextMessage = 'Failed to parse script.';
type: ExecutableType.Script, const expectedError = new Error();
self: expectedScript, const validatorFactory: ExecutableValidatorFactory = () => {
const validatorStub = new ExecutableValidatorStub();
validatorStub.createContextualErrorMessage = (message) => message;
return validatorStub;
}; };
const expectedAssertion: ObjectAssertion<CallScriptData & CodeScriptData> = { // act & assert
value: expectedScript, itThrowsContextualError({
valueName: expectedScript.name, throwingAction: (wrapError) => {
allowedProperties: [ const factoryMock: ScriptFactory = () => {
'name', 'recommend', 'code', 'revertCode', 'call', 'docs', throw expectedError;
],
};
itValidatesType(
(validatorFactory) => {
// act
new TestContext()
.withData(expectedScript)
.withValidatorFactory(validatorFactory)
.parseScript();
// assert
return {
expectedDataToValidate: expectedScript,
expectedErrorContext: expectedContext,
assertValidation: (validator) => validator.assertObject(expectedAssertion),
}; };
new TestContext()
.withScriptFactory(factoryMock)
.withErrorWrapper(wrapError)
.withValidatorFactory(validatorFactory)
.withData(givenData)
.parseScript();
}, },
); expectedWrappedError: expectedError,
}); expectedContextMessage,
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', () => {
// arrange
const givenData = createScriptDataWithCode();
const expectedContextMessage = 'Failed to parse script.';
const expectedError = new Error();
const validatorFactory: ExecutableValidatorFactory = () => {
const validatorStub = new ExecutableValidatorStub();
validatorStub.createContextualErrorMessage = (message) => message;
return validatorStub;
};
// act & assert
itThrowsContextualError({
throwingAction: (wrapError) => {
const factoryMock: ScriptFactory = () => {
throw expectedError;
};
new TestContext()
.withScriptFactory(factoryMock)
.withErrorWrapper(wrapError)
.withValidatorFactory(validatorFactory)
.withData(givenData)
.parseScript();
},
expectedWrappedError: expectedError,
expectedContextMessage,
});
});
}); });
}); });

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';

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()
.withId(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()
.withId(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.docs;
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('1'),
new ScriptStub('2'),
];
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 id = `[${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 withId(id: string): 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(): ReturnType<typeof createCategory> {
return createCategory({
executableId: this.id,
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', () => {
// arrange getEnumValues(RecommendationLevel).forEach((enumValue) => {
for (const expected of getEnumValues(RecommendationLevel)) { // arrange
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)], // arrange
); const expectedExistence = true;
const existingItemId: RepositoryEntityId = 'existing-entity-id';
describe('item exists', () => { const items: readonly RepositoryEntity[] = [
const actual = sut.exists(1); new RepositoryEntityStub('unrelated-entity-1'),
it('returns true', () => expect(actual).to.be.true); new RepositoryEntityStub(existingItemId),
new RepositoryEntityStub('unrelated-entity-2'),
];
const sut = new InMemoryRepository(items);
// act
const actualExistence = sut.exists(existingItemId);
// assert
expect(actualExistence).to.equal(expectedExistence);
}); });
describe('item does not exist', () => { it('returns false when item does not exist', () => {
const actual = sut.exists(99); // arrange
it('returns false', () => expect(actual).to.be.false); 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);
}); });
}); });
it('getItems gets initial items', () => { describe('getItems', () => {
// arrange it('returns initial items', () => {
const expected = [ // arrange
new NumericEntityStub(1), new NumericEntityStub(2), const expectedItems: readonly RepositoryEntity[] = [
new NumericEntityStub(3), new NumericEntityStub(4), new RepositoryEntityStub('expected-item-1'),
]; new RepositoryEntityStub('expected-item-2'),
new RepositoryEntityStub('expected-item-3'),
// act ];
const sut = new InMemoryRepository<number, NumericEntityStub>(expected); // act
const actual = sut.getItems(); const sut = new InMemoryRepository(expectedItems);
const actualItems = sut.getItems();
// assert // assert
expect(actual).to.deep.equal(expected); 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', () => {
// arrange
const sut = new InMemoryRepository<RepositoryEntity>();
const expectedItem = new RepositoryEntityStub('expected-entity-id');
// act
sut.addItem(expectedItem);
// assert
const actualItems = sut.getItems();
expect(actualItems).to.have.lengthOf(1);
expect(actualItems).to.deep.include(expectedItem);
}); });
}); });
it('removeItem removes', () => { describe('removeItem', () => {
// arrange it('decreases length', () => {
const initialItems = [ // arrange
new NumericEntityStub(1), new NumericEntityStub(2), const itemIdToDelete: RepositoryEntityId = 'entity-id-to-be-deleted';
new NumericEntityStub(3), new NumericEntityStub(4), const initialItems: readonly RepositoryEntity[] = [
]; new RepositoryEntityStub('entity-to-be-retained-1'),
const idToDelete = 3; new RepositoryEntityStub(itemIdToDelete),
const expected = { new RepositoryEntityStub('entity-to-be-retained-2'),
length: 3, ];
items: [new NumericEntityStub(1), new NumericEntityStub(2), new NumericEntityStub(4)], const expectedLength = 2;
}; const sut = new InMemoryRepository<RepositoryEntity>(initialItems);
const sut = new InMemoryRepository<number, NumericEntityStub>(initialItems); // act
sut.removeItem(itemIdToDelete);
// act // assert
sut.removeItem(idToDelete); const actualLength = sut.length;
const actual = { expect(actualLength).to.equal(expectedLength);
length: sut.length, });
items: sut.getItems(), it('removes from items', () => {
}; // arrange
const expectedItems: readonly RepositoryEntity[] = [
// assert new RepositoryEntityStub('entity-to-be-retained-1'),
expect(actual.length).to.equal(expected.length); new RepositoryEntityStub('entity-to-be-retained-2'),
expect(actual.items).to.deep.equal(expected.items); ];
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

@@ -5,7 +5,7 @@ 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 { getCategoryNodeId, 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';
describe('ReverterFactory', () => { describe('ReverterFactory', () => {
@@ -24,7 +24,7 @@ describe('ReverterFactory', () => {
it('gets ScriptReverter for script node', () => { it('gets ScriptReverter 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(0).withScript(script));
// act // act

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,7 @@ describe('ScriptReverter', () => {
// act // act
sut.selectWithRevertState(revertState, userSelection); sut.selectWithRevertState(revertState, userSelection);
// assert // assert
expect(scriptSelection.isScriptSelected(script.id, expectedRevert)).to.equal(true); expect(scriptSelection.isScriptSelected(script.executableId, expectedRevert)).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.id).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.id).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'));

View File

@@ -1,10 +1,12 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { useSelectedScriptNodeIds } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseSelectedScriptNodeIds'; import { useSelectedScriptNodeIds } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseSelectedScriptNodeIds';
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 type { Script } from '@/domain/Executables/Script/Script'; import type { Script } from '@/domain/Executables/Script/Script';
import { UseUserSelectionStateStub } from '@tests/unit/shared/Stubs/UseUserSelectionStateStub'; import { UseUserSelectionStateStub } from '@tests/unit/shared/Stubs/UseUserSelectionStateStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub'; import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import type { TreeNodeId } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
import type { Executable } from '@/domain/Executables/Executable';
describe('useSelectedScriptNodeIds', () => { describe('useSelectedScriptNodeIds', () => {
it('returns an empty array when no scripts are selected', () => { it('returns an empty array when no scripts are selected', () => {
@@ -23,7 +25,7 @@ describe('useSelectedScriptNodeIds', () => {
new SelectedScriptStub(new ScriptStub('id-1')), new SelectedScriptStub(new ScriptStub('id-1')),
new SelectedScriptStub(new ScriptStub('id-2')), new SelectedScriptStub(new ScriptStub('id-2')),
]; ];
const parsedNodeIds = new Map<Script, string>([ const parsedNodeIds = new Map<Script, TreeNodeId>([
[selectedScripts[0].script, 'expected-id-1'], [selectedScripts[0].script, 'expected-id-1'],
[selectedScripts[1].script, 'expected-id-2'], [selectedScripts[1].script, 'expected-id-2'],
]); ]);
@@ -47,7 +49,7 @@ describe('useSelectedScriptNodeIds', () => {
new SelectedScriptStub(new ScriptStub('id-1')), new SelectedScriptStub(new ScriptStub('id-1')),
new SelectedScriptStub(new ScriptStub('id-2')), new SelectedScriptStub(new ScriptStub('id-2')),
]; ];
const parsedNodeIds = new Map<Script, string>([ const parsedNodeIds = new Map<Script, TreeNodeId>([
[changedScripts[0].script, 'expected-id-1'], [changedScripts[0].script, 'expected-id-1'],
[changedScripts[1].script, 'expected-id-2'], [changedScripts[1].script, 'expected-id-2'],
]); ]);
@@ -68,9 +70,9 @@ describe('useSelectedScriptNodeIds', () => {
}); });
}); });
type ScriptNodeIdParser = typeof getScriptNodeId; type NodeIdParser = typeof createNodeIdForExecutable;
function createNodeIdParserFromMap(scriptToIdMap: Map<Script, string>): ScriptNodeIdParser { function createNodeIdParserFromMap(scriptToIdMap: Map<Executable, TreeNodeId>): NodeIdParser {
return (script) => { return (script) => {
const expectedId = scriptToIdMap.get(script); const expectedId = scriptToIdMap.get(script);
if (!expectedId) { if (!expectedId) {
@@ -81,12 +83,12 @@ function createNodeIdParserFromMap(scriptToIdMap: Map<Script, string>): ScriptNo
} }
function runHook(scenario?: { function runHook(scenario?: {
readonly scriptNodeIdParser?: ScriptNodeIdParser, readonly scriptNodeIdParser?: NodeIdParser,
readonly useSelectionState?: UseUserSelectionStateStub, readonly useSelectionState?: UseUserSelectionStateStub,
}) { }) {
const useSelectionStateStub = scenario?.useSelectionState ?? new UseUserSelectionStateStub(); const useSelectionStateStub = scenario?.useSelectionState ?? new UseUserSelectionStateStub();
const nodeIdParser: ScriptNodeIdParser = scenario?.scriptNodeIdParser const nodeIdParser: NodeIdParser = scenario?.scriptNodeIdParser
?? ((script) => script.id); ?? ((script) => script.executableId);
const returnObject = useSelectedScriptNodeIds(useSelectionStateStub.get(), nodeIdParser); const returnObject = useSelectedScriptNodeIds(useSelectionStateStub.get(), nodeIdParser);
return { return {
returnObject, returnObject,

View File

@@ -216,29 +216,29 @@ function itExpectedFilterTriggeredEvent(
{ {
description: 'returns true when category exists', description: 'returns true when category exists',
scriptMatches: [], scriptMatches: [],
categoryMatches: [new CategoryStub(1)], categoryMatches: [new CategoryStub('category-match-1')],
givenNode: createNode({ id: '1', hasParent: false }), givenNode: createNode({ id: 'category-match-1', hasParent: false }),
expectedPredicateResult: true, expectedPredicateResult: true,
}, },
{ {
description: 'returns true when script exists', description: 'returns true when script exists',
scriptMatches: [new ScriptStub('a')], scriptMatches: [new ScriptStub('script-match-1')],
categoryMatches: [], categoryMatches: [],
givenNode: createNode({ id: 'a', hasParent: true }), givenNode: createNode({ id: 'script-match-1', hasParent: true }),
expectedPredicateResult: true, expectedPredicateResult: true,
}, },
{ {
description: 'returns false when category is missing', description: 'returns false when category is missing',
scriptMatches: [new ScriptStub('b')], scriptMatches: [new ScriptStub('script-match-1')],
categoryMatches: [new CategoryStub(2)], categoryMatches: [new CategoryStub('category-match-1')],
givenNode: createNode({ id: '1', hasParent: false }), givenNode: createNode({ id: 'unrelated-node', hasParent: false }),
expectedPredicateResult: false, expectedPredicateResult: false,
}, },
{ {
description: 'finds false when script is missing', description: 'finds false when script is missing',
scriptMatches: [new ScriptStub('b')], scriptMatches: [new ScriptStub('script-match-1')],
categoryMatches: [new CategoryStub(1)], categoryMatches: [new CategoryStub('category-match-1')],
givenNode: createNode({ id: 'a', hasParent: true }), givenNode: createNode({ id: 'unrelated-node', hasParent: true }),
expectedPredicateResult: false, expectedPredicateResult: false,
}, },
]; ];
@@ -261,8 +261,8 @@ function itExpectedFilterTriggeredEvent(
expect(event.value.predicate).toBeDefined(); expect(event.value.predicate).toBeDefined();
const actualPredicateResult = event.value.predicate(givenNode); const actualPredicateResult = event.value.predicate(givenNode);
expect(actualPredicateResult).to.equal(expectedPredicateResult, formatAssertionMessage([ expect(actualPredicateResult).to.equal(expectedPredicateResult, formatAssertionMessage([
`Script matches (${scriptMatches.length}): [${scriptMatches.map((s) => s.id).join(', ')}]`, `Script matches (${scriptMatches.length}): [${scriptMatches.map((s) => s.executableId).join(', ')}]`,
`Category matches (${categoryMatches.length}): [${categoryMatches.map((s) => s.id).join(', ')}]`, `Category matches (${categoryMatches.length}): [${categoryMatches.map((s) => s.executableId).join(', ')}]`,
`Expected node: "${givenNode.id}"`, `Expected node: "${givenNode.id}"`,
])); ]));
}); });

View File

@@ -7,7 +7,7 @@ import { CategoryCollectionStateStub } from '@tests/unit/shared/Stubs/CategoryCo
import { UseCollectionStateStub } from '@tests/unit/shared/Stubs/UseCollectionStateStub'; import { UseCollectionStateStub } from '@tests/unit/shared/Stubs/UseCollectionStateStub';
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 type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import type { NodeMetadata } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata'; import type { NodeMetadata } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata';
import { NodeMetadataStub } from '@tests/unit/shared/Stubs/NodeMetadataStub'; import { NodeMetadataStub } from '@tests/unit/shared/Stubs/NodeMetadataStub';
import { convertToNodeInput } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/TreeNodeMetadataConverter'; import { convertToNodeInput } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/TreeNodeMetadataConverter';

View File

@@ -1,5 +1,5 @@
import type { IApplication } from '@/domain/IApplication'; 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 { OperatingSystem } from '@/domain/OperatingSystem';
import type { ProjectDetails } from '@/domain/Project/ProjectDetails'; import type { ProjectDetails } from '@/domain/Project/ProjectDetails';
import { ProjectDetailsStub } from './ProjectDetailsStub'; import { ProjectDetailsStub } from './ProjectDetailsStub';

View File

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

View File

@@ -1,5 +1,5 @@
import type { ProjectDetails } from '@/domain/Project/ProjectDetails'; 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 { getEnumValues } from '@/application/Common/Enum';
import type { CollectionData } from '@/application/collections/'; import type { CollectionData } from '@/application/collections/';
import { OperatingSystem } from '@/domain/OperatingSystem'; 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 { OperatingSystem } from '@/domain/OperatingSystem';
import type { Script } from '@/domain/Executables/Script/Script'; import type { Script } from '@/domain/Executables/Script/Script';
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 { 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 type { FilterContext } from '@/application/Context/State/Filter/FilterContext'; import type { FilterContext } from '@/application/Context/State/Filter/FilterContext';

View File

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

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 { Category } from '@/domain/Executables/Category/Category';
import type { CategoryFactory, CategoryInitParameters } from '@/domain/Executables/Category/CategoryFactory';
import { CategoryStub } from './CategoryStub'; import { CategoryStub } from './CategoryStub';
export function createCategoryFactorySpy(): { export function createCategoryFactorySpy(): {
@@ -10,7 +9,7 @@ export function createCategoryFactorySpy(): {
const createdCategories = new Map<Category, CategoryInitParameters>(); const createdCategories = new Map<Category, CategoryInitParameters>();
return { return {
categoryFactorySpy: (parameters) => { categoryFactorySpy: (parameters) => {
const category = new CategoryStub(55); const category = new CategoryStub('category-from-factory-stub');
createdCategories.set(category, parameters); createdCategories.set(category, parameters);
return category; return category;
}, },

View File

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

View File

@@ -1,6 +1,6 @@
import type { FilterResult } from '@/application/Context/State/Filter/Result/FilterResult'; import type { FilterResult } from '@/application/Context/State/Filter/Result/FilterResult';
import type { FilterStrategy } from '@/application/Context/State/Filter/Strategy/FilterStrategy'; 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 { FilterResultStub } from './FilterResultStub';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls'; import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';

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;
}
}

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