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.
This commit is contained in:
undergroundwires
2024-06-16 11:44:48 +02:00
parent 19ea8dbc5b
commit 48d6dbd700
96 changed files with 1417 additions and 1109 deletions

View File

@@ -0,0 +1,327 @@
import { describe, it, expect } from 'vitest';
import type { Category } from '@/domain/Executables/Category/Category';
import { OperatingSystem } from '@/domain/OperatingSystem';
import type { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
import { getEnumValues } from '@/application/Common/Enum';
import { CategoryCollection } from '@/domain/Collection/CategoryCollection';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import { EnumRangeTestRunner } from '@tests/unit/application/Common/EnumRangeTestRunner';
describe('CategoryCollection', () => {
describe('getScriptsByLevel', () => {
it('filters out scripts without levels', () => {
// arrange
const recommendationLevels = getEnumValues(RecommendationLevel);
const scriptsWithLevels = recommendationLevels.map(
(level, index) => new ScriptStub(`Script${index}`).withLevel(level),
);
const toIgnore = new ScriptStub('script-to-ignore').withLevel(undefined);
for (const currentLevel of recommendationLevels) {
const category = new CategoryStub(0)
.withScripts(...scriptsWithLevels)
.withScript(toIgnore);
const sut = new CategoryCollectionBuilder()
.withActions([category])
.construct();
// act
const actual = sut.getScriptsByLevel(currentLevel);
// assert
expect(actual).to.not.include(toIgnore);
}
});
it(`${RecommendationLevel[RecommendationLevel.Standard]} filters ${RecommendationLevel[RecommendationLevel.Strict]}`, () => {
// arrange
const level = RecommendationLevel.Standard;
const expected = [
new ScriptStub('S1').withLevel(level),
new ScriptStub('S2').withLevel(level),
];
const actions = [
new CategoryStub(3).withScripts(
...expected,
new ScriptStub('S3').withLevel(RecommendationLevel.Strict),
),
];
const sut = new CategoryCollectionBuilder()
.withActions(actions)
.construct();
// act
const actual = sut.getScriptsByLevel(level);
// assert
expect(expected).to.deep.equal(actual);
});
it(`${RecommendationLevel[RecommendationLevel.Strict]} includes ${RecommendationLevel[RecommendationLevel.Standard]}`, () => {
// arrange
const level = RecommendationLevel.Strict;
const expected = [
new ScriptStub('S1').withLevel(RecommendationLevel.Standard),
new ScriptStub('S2').withLevel(RecommendationLevel.Strict),
];
const actions = [
new CategoryStub(3).withScripts(...expected),
];
const sut = new CategoryCollectionBuilder()
.withActions(actions)
.construct();
// act
const actual = sut.getScriptsByLevel(level);
// assert
expect(expected).to.deep.equal(actual);
});
describe('throws when given invalid level', () => {
new EnumRangeTestRunner<RecommendationLevel>((level) => {
// arrange
const sut = new CategoryCollectionBuilder()
.construct();
// act
sut.getScriptsByLevel(level);
})
// assert
.testOutOfRangeThrows()
.testValidValueDoesNotThrow(RecommendationLevel.Standard);
});
});
describe('actions', () => {
it('cannot construct without actions', () => {
// arrange
const categories = [];
// act
function construct() {
new CategoryCollectionBuilder()
.withActions(categories)
.construct();
}
// assert
expect(construct).to.throw('must consist of at least one category');
});
it('cannot construct without scripts', () => {
// arrange
const categories = [
new CategoryStub(3),
new CategoryStub(2),
];
// act
function construct() {
new CategoryCollectionBuilder()
.withActions(categories)
.construct();
}
// assert
expect(construct).to.throw('must consist of at least one script');
});
describe('cannot construct without any recommended scripts', () => {
describe('single missing', () => {
// arrange
const recommendationLevels = getEnumValues(RecommendationLevel);
for (const missingLevel of recommendationLevels) {
it(`when "${RecommendationLevel[missingLevel]}" is missing`, () => {
const expectedError = `none of the scripts are recommended as "${RecommendationLevel[missingLevel]}".`;
const otherLevels = recommendationLevels.filter((level) => level !== missingLevel);
const categories = otherLevels.map(
(level, index) => new CategoryStub(index)
.withScript(
new ScriptStub(`Script${index}`).withLevel(level),
),
);
// act
const construct = () => new CategoryCollectionBuilder()
.withActions(categories)
.construct();
// assert
expect(construct).to.throw(expectedError);
});
}
});
it('multiple are missing', () => {
// arrange
const expectedError = 'none of the scripts are recommended as '
+ `"${RecommendationLevel[RecommendationLevel.Standard]}, "${RecommendationLevel[RecommendationLevel.Strict]}".`;
const categories = [
new CategoryStub(0)
.withScript(
new ScriptStub(`Script${0}`).withLevel(undefined),
),
];
// act
const construct = () => new CategoryCollectionBuilder()
.withActions(categories)
.construct();
// assert
expect(construct).to.throw(expectedError);
});
});
});
describe('totalScripts', () => {
it('returns total of initial scripts', () => {
// arrange
const categories = [
new CategoryStub(1).withScripts(
new ScriptStub('S1').withLevel(RecommendationLevel.Standard),
),
new CategoryStub(2).withScripts(
new ScriptStub('S2'),
new ScriptStub('S3').withLevel(RecommendationLevel.Strict),
),
new CategoryStub(3).withCategories(
new CategoryStub(4).withScripts(new ScriptStub('S4')),
),
];
// act
const sut = new CategoryCollectionBuilder()
.withActions(categories)
.construct();
// assert
expect(sut.totalScripts).to.equal(4);
});
});
describe('totalCategories', () => {
it('returns total of initial categories', () => {
// arrange
const expected = 4;
const categories = [
new CategoryStub(1).withScripts(new ScriptStub('S1').withLevel(RecommendationLevel.Strict)),
new CategoryStub(2).withScripts(new ScriptStub('S2'), new ScriptStub('S3')),
new CategoryStub(3).withCategories(new CategoryStub(4).withScripts(new ScriptStub('S4'))),
];
// act
const sut = new CategoryCollectionBuilder()
.withActions(categories)
.construct();
// assert
expect(sut.totalCategories).to.equal(expected);
});
});
describe('os', () => {
it('sets os as expected', () => {
// arrange
const expected = OperatingSystem.macOS;
// act
const sut = new CategoryCollectionBuilder()
.withOs(expected)
.construct();
// assert
expect(sut.os).to.deep.equal(expected);
});
describe('throws when invalid', () => {
// act
const act = (os: OperatingSystem) => new CategoryCollectionBuilder()
.withOs(os)
.construct();
// assert
new EnumRangeTestRunner(act)
.testOutOfRangeThrows();
});
});
describe('scriptingDefinition', () => {
it('sets scriptingDefinition as expected', () => {
// arrange
const expected = getValidScriptingDefinition();
// act
const sut = new CategoryCollectionBuilder()
.withScripting(expected)
.construct();
// assert
expect(sut.scripting).to.deep.equal(expected);
});
});
describe('getCategory', () => {
it('throws if category is not found', () => {
// arrange
const categoryId = 123;
const expectedError = `Missing category with ID: "${categoryId}"`;
const collection = new CategoryCollectionBuilder()
.withActions([new CategoryStub(456).withMandatoryScripts()])
.construct();
// act
const act = () => collection.getCategory(categoryId);
// assert
expect(act).to.throw(expectedError);
});
it('finds correct category', () => {
// arrange
const categoryId = 123;
const expectedCategory = new CategoryStub(categoryId).withMandatoryScripts();
const collection = new CategoryCollectionBuilder()
.withActions([expectedCategory])
.construct();
// act
const actualCategory = collection.getCategory(categoryId);
// assert
expect(actualCategory).to.equal(expectedCategory);
});
});
describe('getScript', () => {
it('throws if script is not found', () => {
// arrange
const scriptId = 'missingScript';
const expectedError = `missing script: ${scriptId}`;
const collection = new CategoryCollectionBuilder()
.withActions([new CategoryStub(456).withMandatoryScripts()])
.construct();
// act
const act = () => collection.getScript(scriptId);
// assert
expect(act).to.throw(expectedError);
});
it('finds correct script', () => {
// arrange
const scriptId = 'existingScript';
const expectedScript = new ScriptStub(scriptId);
const parentCategory = new CategoryStub(123)
.withMandatoryScripts()
.withScript(expectedScript);
const collection = new CategoryCollectionBuilder()
.withActions([parentCategory])
.construct();
// act
const actualScript = collection.getScript(scriptId);
// assert
expect(actualScript).to.equal(expectedScript);
});
});
});
function getValidScriptingDefinition(): IScriptingDefinition {
return {
fileExtension: '.bat',
language: ScriptingLanguage.batchfile,
startCode: 'start',
endCode: 'end',
};
}
class CategoryCollectionBuilder {
private os = OperatingSystem.Windows;
private actions: readonly Category[] = [
new CategoryStub(1).withMandatoryScripts(),
];
private scriptingDefinition: IScriptingDefinition = getValidScriptingDefinition();
public withOs(os: OperatingSystem): this {
this.os = os;
return this;
}
public withActions(actions: readonly Category[]): this {
this.actions = actions;
return this;
}
public withScripting(scriptingDefinition: IScriptingDefinition): this {
this.scriptingDefinition = scriptingDefinition;
return this;
}
public construct(): CategoryCollection {
return new CategoryCollection({
os: this.os,
actions: this.actions,
scripting: this.scriptingDefinition,
});
}
}