Refactor text utilities and expand their usage

This commit refactors existing text utility functions into the
application layer for broad reuse and integrates them across
the codebase. Initially, these utilities were confined to test
code, which limited their application.

Changes:

- Move text utilities to the application layer.
- Centralize text utilities into dedicated files for better
  maintainability.
- Improve robustness of utility functions with added type checks.
- Replace duplicated logic with centralized utility functions
  throughout the codebase.
- Expand unit tests to cover refactored code parts.
This commit is contained in:
undergroundwires
2024-07-18 20:49:21 +02:00
parent 8d7a7eb434
commit 851917e049
45 changed files with 563 additions and 117 deletions

View File

@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest';
import { filterEmptyStrings, type OptionalString } from '@/application/Common/Text/FilterEmptyStrings';
import { IsArrayStub } from '@tests/unit/shared/Stubs/IsArrayStub';
import type { isArray } from '@/TypeHelpers';
describe('filterEmptyStrings', () => {
describe('filtering behavior', () => {
// arrange
const testScenarios: readonly {
readonly description: string;
readonly texts: readonly OptionalString[];
readonly expected: readonly string[];
}[] = [
{
description: 'filters out non-string entries',
texts: ['Hello', '', 'World', null, 'Test', undefined],
expected: ['Hello', 'World', 'Test'],
},
{
description: 'returns empty array for no valid strings',
texts: [null, undefined, ''],
expected: [],
},
{
description: 'preserves all valid strings',
texts: ['Hello', 'World', 'Test'],
expected: ['Hello', 'World', 'Test'],
},
];
testScenarios.forEach(({
description, texts, expected,
}) => {
it(description, () => {
const context = new TestContext()
.withTexts(texts);
// act
const result = context.filterEmptyStrings();
// assert
expect(result).to.deep.equal(expected);
});
});
});
describe('error handling', () => {
it('throws for non-array input', () => {
// arrange
const nonArrayInput = 'Hello';
const isArray = new IsArrayStub()
.withPredeterminedResult(false)
.get();
const expectedErrorMessage = `Invalid input: Expected an array, but received type ${typeof nonArrayInput}.`;
const context = new TestContext()
.withTexts(nonArrayInput as unknown as OptionalString[])
.withIsArrayType(isArray);
// act
const act = () => context.filterEmptyStrings();
// assert
expect(act).toThrow(expectedErrorMessage);
});
it('throws for invalid item types in array', () => {
// arrange
const invalidInput: unknown[] = ['Hello', 42, 'World']; // Number is invalid
const expectedErrorMessage = 'Invalid array items: Expected items as string, undefined, or null. Received invalid types: number.';
const context = new TestContext()
.withTexts(invalidInput as OptionalString[]);
// act
const act = () => context.filterEmptyStrings();
// assert
expect(act).to.throw(expectedErrorMessage);
});
});
});
class TestContext {
private texts: readonly OptionalString[] = [
`[${TestContext.name}] text to stay after filtering`,
];
private isArrayType: typeof isArray = new IsArrayStub()
.get();
public withTexts(texts: readonly OptionalString[]): this {
this.texts = texts;
return this;
}
public withIsArrayType(isArrayType: typeof isArray): this {
this.isArrayType = isArrayType;
return this;
}
public filterEmptyStrings(): ReturnType<typeof filterEmptyStrings> {
return filterEmptyStrings(
this.texts,
this.isArrayType,
);
}
}

View File

@@ -0,0 +1,130 @@
import { describe, it, expect } from 'vitest';
import { indentText } from '@/application/Common/Text/IndentText';
import { IsStringStub } from '@tests/unit/shared/Stubs/IsStringStub';
import type { isString } from '@/TypeHelpers';
type IndentLevel = Parameters<typeof indentText>['1'];
const TestLineSeparator = '[TEST-LINE-SEPARATOR]';
describe('indentText', () => {
describe('text indentation', () => {
const testScenarios: readonly {
readonly description: string;
readonly text: string;
readonly indentLevel: IndentLevel;
readonly expected: string;
}[] = [
{
description: 'indents multiple lines with single tab',
text: createMultilineTestInput('Hello', 'World', 'Test'),
indentLevel: 1,
expected: '\tHello\n\tWorld\n\tTest',
},
{
description: 'indents multiple lines with two tabs',
text: createMultilineTestInput('Hello', 'World', 'Test'),
indentLevel: 2,
expected: '\t\tHello\n\t\tWorld\n\t\tTest',
},
{
description: 'indents single line with one tab',
text: 'Hello World',
indentLevel: 1,
expected: '\tHello World',
},
{
description: 'preserves empty string without indentation',
text: '',
indentLevel: 1,
expected: '',
},
{
description: 'defaults to one tab when indent level is unspecified',
text: createMultilineTestInput('Hello', 'World'),
indentLevel: undefined,
expected: '\tHello\n\tWorld',
},
];
testScenarios.forEach(({
description, text, indentLevel, expected,
}) => {
it(description, () => {
const context = new TextContext()
.withText(text)
.withIndentLevel(indentLevel);
// act
const actualText = context.indentText();
// assert
expect(actualText).to.equal(expected);
});
});
});
describe('error handling', () => {
it('throws for non-string input', () => {
// arrange
const invalidInput = 42;
const expectedErrorMessage = `Indentation error: The input must be a string. Received type: ${typeof invalidInput}.`;
const isString = new IsStringStub()
.withPredeterminedResult(false)
.get();
const context = new TextContext()
.withText(invalidInput as unknown as string /* bypass compiler checks */)
.withIsStringType(isString);
// act
const act = () => context.indentText();
// assert
expect(act).toThrow(expectedErrorMessage);
});
it('throws for indentation level below one', () => {
// arrange
const indentLevel = 0;
const expectedErrorMessage = `Indentation error: The indent level must be a positive integer. Received: ${indentLevel}.`;
const context = new TextContext()
.withIndentLevel(indentLevel);
// act
const act = () => context.indentText();
// assert
expect(act).toThrow(expectedErrorMessage);
});
});
});
function createMultilineTestInput(...lines: readonly string[]): string {
return lines.join(TestLineSeparator);
}
class TextContext {
private text = `[${TextContext.name}] text to indent`;
private indentLevel: IndentLevel = undefined;
private isStringType: typeof isString = new IsStringStub().get();
public withText(text: string): this {
this.text = text;
return this;
}
public withIndentLevel(indentLevel: IndentLevel): this {
this.indentLevel = indentLevel;
return this;
}
public withIsStringType(isStringType: typeof isString): this {
this.isStringType = isStringType;
return this;
}
public indentText(): ReturnType<typeof indentText> {
return indentText(
this.text,
this.indentLevel,
{
splitIntoLines: (text) => text.split(TestLineSeparator),
isStringType: this.isStringType,
},
);
}
}

View File

@@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest';
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
import type { isString } from '@/TypeHelpers';
import { IsStringStub } from '@tests/unit/shared/Stubs/IsStringStub';
describe('splitTextIntoLines', () => {
describe('splits correctly', () => {
// arrange
const testScenarios: readonly {
readonly description: string;
readonly text: string;
readonly expectedLines: readonly string[];
} [] = [
{
description: 'handles Unix-like line separator',
text: 'Hello\nWorld\nTest',
expectedLines: ['Hello', 'World', 'Test'],
},
{
description: 'handles Windows line separator',
text: 'Hello\r\nWorld\r\nTest',
expectedLines: ['Hello', 'World', 'Test'],
},
{
description: 'handles mixed indentation (both Unix-like and Windows)',
text: 'Hello\r\nWorld\nTest',
expectedLines: ['Hello', 'World', 'Test'],
},
{
description: 'returns an array with one element when no new lines',
text: 'Hello World',
expectedLines: ['Hello World'],
},
{
description: 'preserves empty lines between text lines',
text: 'Hello\n\nWorld\n\n\nTest\n',
expectedLines: ['Hello', '', 'World', '', '', 'Test', ''],
},
{
description: 'handles empty strings',
text: '',
expectedLines: [''],
},
];
testScenarios.forEach(({
description, text, expectedLines,
}) => {
it(description, () => {
const testContext = new TestContext()
.withText(text);
// act
const result = testContext.splitText();
// assert
expect(result).to.deep.equal(expectedLines);
});
});
});
it('checks for string type', () => {
// arrange
const invalidInput = 42;
const errorMessage = `Line splitting error: Expected a string but received type '${typeof invalidInput}'.`;
const isString = new IsStringStub()
.withPredeterminedResult(false)
.get();
// act
const act = () => new TestContext()
.withText(invalidInput as unknown as string)
.withIsStringType(isString)
.splitText();
// assert
expect(act).to.throw(errorMessage);
});
});
class TestContext {
private isStringType: typeof isString = new IsStringStub().get();
private text: string = `[${TestContext.name}] text value`;
public withText(text: string): this {
this.text = text;
return this;
}
public withIsStringType(isStringType: typeof isString): this {
this.isStringType = isStringType;
return this;
}
public splitText(): ReturnType<typeof splitTextIntoLines> {
return splitTextIntoLines(
this.text,
this.isStringType,
);
}
}

View File

@@ -5,6 +5,7 @@ import { CodePosition } from '@/application/Context/State/Code/Position/CodePosi
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
describe('CodeChangedEvent', () => {
describe('ctor', () => {
@@ -19,16 +20,34 @@ describe('CodeChangedEvent', () => {
[new SelectedScriptStub(new ScriptStub('2')), new CodePosition(0, nonExistingLine2)],
]);
// act
let errorText = '';
try {
const actualErrorMessage = collectExceptionMessage(() => {
new CodeChangedEventBuilder()
.withCode(code)
.withNewScripts(newScripts)
.build();
} catch (error) { errorText = error.message; }
});
// assert
expect(errorText).to.include(nonExistingLine1);
expect(errorText).to.include(nonExistingLine2);
expect(actualErrorMessage).to.include(nonExistingLine1);
expect(actualErrorMessage).to.include(nonExistingLine2);
});
it('invalid line position validation counts empty lines', () => {
// arrange
const totalEmptyLines = 5;
const code = '\n'.repeat(totalEmptyLines);
// If empty lines would not be counted, this would result in error
const existingLineEnd = totalEmptyLines;
const newScripts = new Map<SelectedScript, ICodePosition>([
[new SelectedScriptStub(new ScriptStub('1')), new CodePosition(0, existingLineEnd)],
]);
// act
const act = () => {
new CodeChangedEventBuilder()
.withCode(code)
.withNewScripts(newScripts)
.build();
};
// assert
expect(act).to.not.throw();
});
describe('does not throw with valid code position', () => {
// arrange

View File

@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { CodeBuilder } from '@/application/Context/State/Code/Generation/CodeBuilder';
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
describe('CodeBuilder', () => {
class CodeBuilderConcrete extends CodeBuilder {
@@ -47,10 +48,24 @@ describe('CodeBuilder', () => {
.appendLine(expected);
// assert
const result = sut.toString();
const lines = getLines(result);
const lines = splitTextIntoLines(result);
expect(lines[1]).to.equal('str');
});
describe('append multi-line string as multiple lines', () => {
describe('append multi-line string correctly', () => {
it('appends multi-line string with empty lines preserved', () => {
// arrange
const sut = new CodeBuilderConcrete();
const expectedLines: string[] = ['', 'line1', '', 'line2', '', '', 'line3', '', ''];
const multilineInput = expectedLines.join('\n');
// act
sut.appendLine(multilineInput);
const actual = sut.toString();
// assert
const actualLines = splitTextIntoLines(actual);
expect(actualLines).to.deep.equal(expectedLines);
});
describe('recognizes different line terminators', () => {
const delimiters = ['\n', '\r\n', '\r'];
for (const delimiter of delimiters) {
@@ -64,7 +79,7 @@ describe('CodeBuilder', () => {
sut.appendLine(code);
// assert
const result = sut.toString();
const lines = getLines(result);
const lines = splitTextIntoLines(result);
expect(lines).to.have.lengthOf(2);
expect(lines[0]).to.equal(line1);
expect(lines[1]).to.equal(line2);
@@ -111,7 +126,7 @@ describe('CodeBuilder', () => {
sut.appendTrailingHyphensCommentLine(totalHyphens);
// assert
const result = sut.toString();
const lines = getLines(result);
const lines = splitTextIntoLines(result);
expect(lines[0]).to.equal(expected);
});
it('appendCommentLine', () => {
@@ -126,7 +141,7 @@ describe('CodeBuilder', () => {
.appendCommentLine(comment)
.toString();
// assert
const lines = getLines(result);
const lines = splitTextIntoLines(result);
expect(lines[0]).to.equal(expected);
});
it('appendCommentLineWithHyphensAround', () => {
@@ -142,7 +157,7 @@ describe('CodeBuilder', () => {
.appendCommentLineWithHyphensAround(sectionName, totalHyphens)
.toString();
// assert
const lines = getLines(result);
const lines = splitTextIntoLines(result);
expect(lines[1]).to.equal(expected);
});
describe('currentLine', () => {
@@ -180,7 +195,3 @@ describe('CodeBuilder', () => {
});
});
});
function getLines(text: string): string[] {
return text.split(/\r\n|\r|\n/);
}

View File

@@ -1,4 +1,5 @@
import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { indentText } from '@/application/Common/Text/IndentText';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
export function expectEqualSelectedScripts(
@@ -37,11 +38,11 @@ function expectSameRevertStates(
expect(scriptsWithDifferentRevertStates).to.have.lengthOf(0, formatAssertionMessage([
'Scripts with different revert states:',
scriptsWithDifferentRevertStates
.map((s) => [
.map((s) => indentText([
`Script ID: "${s.id}"`,
`Actual revert state: "${s.revert}"`,
`Expected revert state: "${expected.find((existing) => existing.id === s.id)?.revert ?? 'unknown'}"`,
].map((line) => `\t${line}`).join('\n'))
].join('\n')))
.join('\n---\n'),
]));
}

View File

@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import { CustomError } from '@/application/Common/CustomError';
import { wrapErrorWithAdditionalContext } from '@/application/Parser/Common/ContextualError';
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
describe('wrapErrorWithAdditionalContext', () => {
it('preserves the original error when wrapped', () => {
@@ -81,7 +82,7 @@ describe('wrapErrorWithAdditionalContext', () => {
.wrap();
// assert
const messageLines = secondError.message.split('\n');
const messageLines = splitTextIntoLines(secondError.message);
expect(messageLines).to.contain(`1: ${expectedFirstContext}`);
expect(messageLines).to.contain(`2: ${expectedSecondContext}`);
});

View File

@@ -1,7 +1,7 @@
import type { ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { indentText } from '@/application/Common/Text/IndentText';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { indentText } from '@tests/shared/Text';
import { ErrorWrapperStub } from '@tests/unit/shared/Stubs/ErrorWrapperStub';
interface ContextualErrorTestScenario {

View File

@@ -18,8 +18,8 @@ import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { ScriptParserStub } from '@tests/unit/shared/Stubs/ScriptParserStub';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { indentText } from '@tests/shared/Text';
import type { NonEmptyCollectionAssertion, ObjectAssertion } from '@/application/Parser/Common/TypeValidator';
import { indentText } from '@/application/Common/Text/IndentText';
import { itThrowsContextualError } from '../Common/ContextualErrorTester';
import { itValidatesName, itValidatesType, itAsserts } from './Validation/ExecutableValidationTester';
import { generateDataValidationTestScenarios } from './Validation/DataValidationTestScenarioGenerator';

View File

@@ -16,8 +16,8 @@ import { FunctionParameterStub } from '@tests/unit/shared/Stubs/FunctionParamete
import type { IReadOnlyFunctionParameterCollection } from '@/application/Parser/Executable/Script/Compiler/Function/Parameter/IFunctionParameterCollection';
import type { ExpressionPositionFactory } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/ExpressionPositionFactory';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { indentText } from '@tests/shared/Text';
import type { FunctionParameterCollectionFactory } from '@/application/Parser/Executable/Script/Compiler/Function/Parameter/FunctionParameterCollectionFactory';
import { indentText } from '@/application/Common/Text/IndentText';
describe('RegexParser', () => {
describe('findExpressions', () => {

View File

@@ -5,6 +5,7 @@ import { itEachAbsentCollectionValue, itEachAbsentStringValue } from '@tests/uni
import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests';
import type { ICodeLine } from '@/application/Parser/Executable/Script/Validation/ICodeLine';
import type { ICodeValidationRule, IInvalidCodeLine } from '@/application/Parser/Executable/Script/Validation/ICodeValidationRule';
import { indentText } from '@/application/Common/Text/IndentText';
describe('CodeValidator', () => {
describe('instance', () => {
@@ -64,6 +65,19 @@ describe('CodeValidator', () => {
const actualLineIndexes = spy.receivedLines[0].map((line) => line.index);
expect(actualLineIndexes).to.deep.equal(expectedIndexes);
});
it('counts empty lines', () => {
// arrange
const expectedTotalEmptyLines = 4;
const code = '\n'.repeat(expectedTotalEmptyLines - 1);
const spy = new CodeValidationRuleStub();
const sut = new CodeValidator();
// act
sut.throwIfInvalid(code, [spy]);
// expect
expect(spy.receivedLines).has.lengthOf(1);
const actualLines = spy.receivedLines[0];
expect(actualLines).to.have.lengthOf(expectedTotalEmptyLines);
});
it('matches texts with indexes as expected', () => {
// arrange
const expected: readonly ICodeLine[] = [
@@ -145,7 +159,7 @@ class ExpectedErrorBuilder {
public withErrorLine(text: string, error: string) {
return this
.withNumberedLine(`${text}`)
.withLine(`\t${error}`);
.withLine(indentText(`${error}`));
}
public buildError(): string {

View File

@@ -5,9 +5,9 @@ import { ExecutableValidatorStub } from '@tests/unit/shared/Stubs/ExecutableVali
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import type { FunctionKeys } from '@/TypeHelpers';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { indentText } from '@tests/shared/Text';
import { TypeValidatorStub } from '@tests/unit/shared/Stubs/TypeValidatorStub';
import { expectDeepIncludes } from '@tests/shared/Assertions/ExpectDeepIncludes';
import { indentText } from '@/application/Common/Text/IndentText';
type ValidationTestFunction<TExpectation> = (
factory: ExecutableValidatorFactory,

View File

@@ -2,6 +2,7 @@ import { readdirSync, readFileSync } from 'node:fs';
import { resolve, join, basename } from 'node:path';
import { describe, it, expect } from 'vitest';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
/*
A common mistake when working with yaml files to forget mentioning that a value should
@@ -42,8 +43,7 @@ async function findBadLineNumbers(fileContent: string): Promise<number[]> {
function findLineNumbersEndingWith(content: string, ending: string): number[] {
sanityCheck(content, ending);
return content
.split(/\r\n|\r|\n/)
return splitTextIntoLines(content)
.map((line, index) => ({ text: line, index }))
.filter((line) => line.text.trim().endsWith(ending))
.map((line) => line.index + 1 /* first line is 1, not 0 */);

View File

@@ -16,6 +16,7 @@ import { TreeNodeStateDescriptorStub } from '@tests/unit/shared/Stubs/TreeNodeSt
import { TreeNodeStateAccessStub } from '@tests/unit/shared/Stubs/TreeNodeStateAccessStub';
import type { IEventSubscriptionCollection } from '@/infrastructure/Events/IEventSubscriptionCollection';
import type { FunctionKeys } from '@/TypeHelpers';
import { indentText } from '@/application/Common/Text/IndentText';
import type { Ref } from 'vue';
describe('useNodeStateChangeAggregator', () => {
@@ -277,10 +278,10 @@ function buildAssertionMessage(
return [
'\n',
`Expected nodes (${nodes.length}):`,
nodes.map((node) => `\tid: ${node.id}\n\tstate: ${JSON.stringify(node.state.current)}`).join('\n-\n'),
nodes.map((node) => indentText(`id: ${node.id}\nstate: ${JSON.stringify(node.state.current)}`)).join('\n-\n'),
'\n',
`Actual called args (${calledArgs.length}):`,
calledArgs.map((args) => `\tid: ${args.node.id}\n\tnewState: ${JSON.stringify(args.newState)}`).join('\n-\n'),
calledArgs.map((args) => indentText(`id: ${args.node.id}\nnewState: ${JSON.stringify(args.newState)}`)).join('\n-\n'),
'\n',
].join('\n');
}

View File

@@ -6,6 +6,7 @@ import {
import { TimerStub } from '@tests/unit/shared/Stubs/TimerStub';
import { watchPromiseState, flushPromiseResolutionQueue } from '@tests/unit/shared/PromiseInspection';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { indentText } from '@/application/Common/Text/IndentText';
describe('UseExpandCollapseAnimation', () => {
describe('useExpandCollapseAnimation', () => {
@@ -85,7 +86,7 @@ function runSharedTestsForAnimation(
`Initial style value: ${expectedStyleValues}`,
'All styles:',
...Object.entries(expectedStyleValues)
.map(([k, value]) => `\t- ${k} > actual: "${element.style[k]}" | expected: "${value}"`),
.map(([k, value]) => indentText(`- ${k} > actual: "${element.style[k]}" | expected: "${value}"`)),
]));
});
});

View File

@@ -1,4 +1,5 @@
import type { ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
export class ErrorWrapperStub {
private errorToReturn: Error | undefined;
@@ -60,8 +61,7 @@ function getLimitedStackTrace(
if (!stack) {
return 'No stack trace available';
}
return stack
.split('\n')
return splitTextIntoLines(stack)
.slice(0, limit + 1)
.join('\n');
}

View File

@@ -0,0 +1,14 @@
import type { isArray } from '@/TypeHelpers';
export class IsArrayStub {
private predeterminedResult = true;
public withPredeterminedResult(predeterminedResult: boolean): this {
this.predeterminedResult = predeterminedResult;
return this;
}
public get(): typeof isArray {
return (value: unknown): value is Array<unknown> => this.predeterminedResult;
}
}

View File

@@ -0,0 +1,14 @@
import type { isString } from '@/TypeHelpers';
export class IsStringStub {
private predeterminedResult = true;
public withPredeterminedResult(predeterminedResult: boolean): this {
this.predeterminedResult = predeterminedResult;
return this;
}
public get(): typeof isString {
return (value: unknown): value is string => this.predeterminedResult;
}
}