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

@@ -91,7 +91,7 @@ async function verifyFilesExist(directoryPath, filePatterns) {
if (!match) {
die(
`No file matches the pattern ${pattern.source} in directory \`${directoryPath}\``,
`\nFiles in directory:\n${files.map((file) => `\t- ${file}`).join('\n')}`,
`\nFiles in directory:\n${files.map((file) => `- ${file}`).join('\n')}`,
);
}
}

View File

@@ -0,0 +1,25 @@
import { isArray } from '@/TypeHelpers';
export type OptionalString = string | undefined | null;
export function filterEmptyStrings(
texts: readonly OptionalString[],
isArrayType: typeof isArray = isArray,
): string[] {
if (!isArrayType(texts)) {
throw new Error(`Invalid input: Expected an array, but received type ${typeof texts}.`);
}
assertArrayItemsAreStringLike(texts);
return texts
.filter((title): title is string => Boolean(title));
}
function assertArrayItemsAreStringLike(
texts: readonly unknown[],
): asserts texts is readonly OptionalString[] {
const invalidItems = texts.filter((item) => !(typeof item === 'string' || item === undefined || item === null));
if (invalidItems.length > 0) {
const invalidTypes = invalidItems.map((item) => typeof item).join(', ');
throw new Error(`Invalid array items: Expected items as string, undefined, or null. Received invalid types: ${invalidTypes}.`);
}
}

View File

@@ -0,0 +1,29 @@
import { isString } from '@/TypeHelpers';
import { splitTextIntoLines } from './SplitTextIntoLines';
export function indentText(
text: string,
indentLevel = 1,
utilities: TextIndentationUtilities = DefaultUtilities,
): string {
if (!utilities.isStringType(text)) {
throw new Error(`Indentation error: The input must be a string. Received type: ${typeof text}.`);
}
if (indentLevel <= 0) {
throw new Error(`Indentation error: The indent level must be a positive integer. Received: ${indentLevel}.`);
}
const indentation = '\t'.repeat(indentLevel);
return utilities.splitIntoLines(text)
.map((line) => (line ? `${indentation}${line}` : line))
.join('\n');
}
interface TextIndentationUtilities {
readonly splitIntoLines: typeof splitTextIntoLines;
readonly isStringType: typeof isString;
}
const DefaultUtilities: TextIndentationUtilities = {
splitIntoLines: splitTextIntoLines,
isStringType: isString,
};

View File

@@ -0,0 +1,11 @@
import { isString } from '@/TypeHelpers';
export function splitTextIntoLines(
text: string,
isStringType = isString,
): string[] {
if (!isStringType(text)) {
throw new Error(`Line splitting error: Expected a string but received type '${typeof text}'.`);
}
return text.split(/\r\n|\r|\n/);
}

View File

@@ -1,6 +1,7 @@
import type { Script } from '@/domain/Executables/Script/Script';
import type { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
import type { ICodeChangedEvent } from './ICodeChangedEvent';
export class CodeChangedEvent implements ICodeChangedEvent {
@@ -52,12 +53,12 @@ export class CodeChangedEvent implements ICodeChangedEvent {
}
function ensureAllPositionsExist(script: string, positions: ReadonlyArray<ICodePosition>) {
const totalLines = script.split(/\r\n|\r|\n/).length;
const totalLines = splitTextIntoLines(script).length;
const missingPositions = positions.filter((position) => position.endLine > totalLines);
if (missingPositions.length > 0) {
throw new Error(
`Out of range script end line: "${missingPositions.map((pos) => pos.endLine).join('", "')}"`
+ `(total code lines: ${totalLines}).`,
+ ` (total code lines: ${totalLines}).`,
);
}
}

View File

@@ -1,3 +1,4 @@
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
import type { ICodeBuilder } from './ICodeBuilder';
const TotalFunctionSeparatorChars = 58;
@@ -15,7 +16,7 @@ export abstract class CodeBuilder implements ICodeBuilder {
this.lines.push('');
return this;
}
const lines = code.match(/[^\r\n]+/g);
const lines = splitTextIntoLines(code);
if (lines) {
this.lines.push(...lines);
}

View File

@@ -1,3 +1,4 @@
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
import type { IPipe } from '../IPipe';
export class InlinePowerShell implements IPipe {
@@ -89,10 +90,6 @@ function inlineComments(code: string): string {
*/
}
function getLines(code: string): string[] {
return (code?.split(/\r\n|\r|\n/) || []);
}
/*
Merges inline here-strings to a single lined string with Windows line terminator (\r\n)
https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules?view=powershell-7.4#here-strings
@@ -102,7 +99,7 @@ function mergeHereStrings(code: string) {
return code.replaceAll(regex, (_$, quotes, scope) => {
const newString = getHereStringHandler(quotes);
const escaped = scope.replaceAll(quotes, newString.escapedQuotes);
const lines = getLines(escaped);
const lines = splitTextIntoLines(escaped);
const inlined = lines.join(newString.separator);
const quoted = `${newString.quotesAround}${inlined}${newString.quotesAround}`;
return quoted;
@@ -159,7 +156,7 @@ function mergeLinesWithBacktick(code: string) {
}
function mergeNewLines(code: string) {
return getLines(code)
return splitTextIntoLines(code)
.map((line) => line.trim())
.filter((line) => line.length > 0)
.join('; ');

View File

@@ -1,3 +1,4 @@
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
import type { CompiledCode } from '../CompiledCode';
import type { CodeSegmentMerger } from './CodeSegmentMerger';
@@ -8,11 +9,9 @@ export class NewlineCodeSegmentMerger implements CodeSegmentMerger {
}
return {
code: joinCodeParts(codeSegments.map((f) => f.code)),
revertCode: joinCodeParts(
codeSegments
.map((f) => f.revertCode)
.filter((code): code is string => Boolean(code)),
),
revertCode: joinCodeParts(filterEmptyStrings(
codeSegments.map((f) => f.revertCode),
)),
};
}
}

View File

@@ -3,6 +3,7 @@ import type { IExpressionsCompiler } from '@/application/Parser/Executable/Scrip
import { FunctionBodyType, type ISharedFunction } from '@/application/Parser/Executable/Script/Compiler/Function/ISharedFunction';
import type { FunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCall';
import type { CompiledCode } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/CompiledCode';
import { indentText } from '@/application/Common/Text/IndentText';
import type { SingleCallCompilerStrategy } from '../SingleCallCompilerStrategy';
export class InlineFunctionCallCompiler implements SingleCallCompilerStrategy {
@@ -22,10 +23,12 @@ export class InlineFunctionCallCompiler implements SingleCallCompilerStrategy {
if (calledFunction.body.type !== FunctionBodyType.Code) {
throw new Error([
'Unexpected function body type.',
`\tExpected: "${FunctionBodyType[FunctionBodyType.Code]}"`,
`\tActual: "${FunctionBodyType[calledFunction.body.type]}"`,
indentText([
`Expected: "${FunctionBodyType[FunctionBodyType.Code]}"`,
`Actual: "${FunctionBodyType[calledFunction.body.type]}"`,
].join('\n')),
'Function:',
`\t${JSON.stringify(callToFunction)}`,
indentText(JSON.stringify(callToFunction)),
].join('\n'));
}
const { code } = calledFunction.body;

View File

@@ -9,6 +9,7 @@ import { NoDuplicatedLines } from '@/application/Parser/Executable/Script/Valida
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
import { isArray, isNullOrUndefined, isPlainObject } from '@/TypeHelpers';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
import { createFunctionWithInlineCode, createCallerFunction } from './SharedFunction';
import { SharedFunctionCollection } from './SharedFunctionCollection';
import { parseFunctionCalls, type FunctionCallsParser } from './Call/FunctionCallsParser';
@@ -82,8 +83,7 @@ function validateCode(
syntax: ILanguageSyntax,
validator: ICodeValidator,
): void {
[data.code, data.revertCode]
.filter((code): code is string => Boolean(code))
filterEmptyStrings([data.code, data.revertCode])
.forEach(
(code) => validator.throwIfInvalid(
code,
@@ -204,9 +204,9 @@ function ensureNoDuplicateCode(functions: readonly FunctionData[]) {
if (duplicateCodes.length > 0) {
throw new Error(`duplicate "code" in functions: ${printList(duplicateCodes)}`);
}
const duplicateRevertCodes = getDuplicates(callFunctions
.map((func) => func.revertCode)
.filter((code): code is string => Boolean(code)));
const duplicateRevertCodes = getDuplicates(filterEmptyStrings(
callFunctions.map((func) => func.revertCode),
));
if (duplicateRevertCodes.length > 0) {
throw new Error(`duplicate "revertCode" in functions: ${printList(duplicateRevertCodes)}`);
}

View File

@@ -6,6 +6,7 @@ import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { createScriptCode, type ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
import { FunctionCallSequenceCompiler } from './Function/Call/Compiler/FunctionCallSequenceCompiler';
import { parseFunctionCalls } from './Function/Call/FunctionCallsParser';
import { parseSharedFunctions, type SharedFunctionsParser } from './Function/SharedFunctionsParser';
@@ -71,9 +72,7 @@ export class ScriptCompiler implements IScriptCompiler {
}
function validateCompiledCode(compiledCode: CompiledCode, validator: ICodeValidator): void {
[compiledCode.code, compiledCode.revertCode]
.filter((code): code is string => Boolean(code))
.map((code) => code as string)
filterEmptyStrings([compiledCode.code, compiledCode.revertCode])
.forEach(
(code) => validator.throwIfInvalid(
code,

View File

@@ -10,6 +10,7 @@ import type { ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptC
import { createScriptCode } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
import type { Script } from '@/domain/Executables/Script/Script';
import { createEnumParser, type EnumParser } from '@/application/Common/Enum';
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
import { parseDocs, type DocsParser } from '../DocumentationParser';
import { ExecutableType } from '../Validation/ExecutableType';
import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from '../Validation/ExecutableValidator';
@@ -86,8 +87,7 @@ function validateHardcodedCodeWithoutCalls(
validator: ICodeValidator,
syntax: ILanguageSyntax,
) {
[scriptCode.execute, scriptCode.revert]
.filter((code): code is string => Boolean(code))
filterEmptyStrings([scriptCode.execute, scriptCode.revert])
.forEach(
(code) => validator.throwIfInvalid(
code,

View File

@@ -1,3 +1,4 @@
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
import type { ICodeLine } from './ICodeLine';
import type { ICodeValidationRule, IInvalidCodeLine } from './ICodeValidationRule';
import type { ICodeValidator } from './ICodeValidator';
@@ -24,12 +25,11 @@ export class CodeValidator implements ICodeValidator {
}
function extractLines(code: string): ICodeLine[] {
return code
.split(/\r\n|\r|\n/)
.map((lineText, lineIndex): ICodeLine => ({
index: lineIndex + 1,
text: lineText,
}));
const lines = splitTextIntoLines(code);
return lines.map((lineText, lineIndex): ICodeLine => ({
index: lineIndex + 1,
text: lineText,
}));
}
function printLines(

View File

@@ -8,6 +8,7 @@
<script lang="ts">
import { defineComponent, type PropType, computed } from 'vue';
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
import MarkdownText from '../Markdown/MarkdownText.vue';
export default defineComponent({
@@ -43,7 +44,7 @@ function formatAsMarkdownListItem(content: string): string {
if (content.length === 0) {
throw new Error('missing content');
}
const lines = content.split(/\r\n|\r|\n/);
const lines = splitTextIntoLines(content);
return `- ${lines[0]}${lines.slice(1)
.map((line) => `\n ${line}`)
.join()}`;
@@ -61,3 +62,4 @@ function formatAsMarkdownListItem(content: string): string {
font-size: $font-size-absolute-normal;
}
</style>
@/application/Text/SplitTextIntoLines

View File

@@ -1,4 +1,5 @@
import { indentText, splitTextIntoLines } from '@tests/shared/Text';
import { indentText } from '@/application/Common/Text/IndentText';
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
import { log, die } from '../utils/log';
import { readAppLogFile } from './app-logs';
import { STDERR_IGNORE_PATTERNS } from './error-ignore-patterns';
@@ -172,5 +173,5 @@ function describeError(
function getNonEmptyLines(text: string) {
return splitTextIntoLines(text)
.filter((line) => line?.trim().length > 0);
.filter((line) => line.trim().length > 0);
}

View File

@@ -1,4 +1,5 @@
import { filterEmpty } from '@tests/shared/Text';
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
import { runCommand } from '../../utils/run-command';
import { log, LogLevel } from '../../utils/log';
import { SupportedPlatform, CURRENT_PLATFORM } from '../../utils/platform';
@@ -56,7 +57,7 @@ async function captureTitlesOnLinux(processId: number): Promise<string[]> {
return [];
}
const windowIds = windowIdsOutput.trim().split('\n');
const windowIds = splitTextIntoLines(windowIdsOutput.trim());
const titles = await Promise.all(windowIds.map(async (windowId) => {
const { stdout: titleOutput, error: titleError } = await runCommand(
@@ -68,7 +69,7 @@ async function captureTitlesOnLinux(processId: number): Promise<string[]> {
return titleOutput.trim();
}));
return filterEmpty(titles);
return filterEmptyStrings(titles);
}
let hasAssistiveAccessOnMac = true;
@@ -78,7 +79,7 @@ async function captureTitlesOnMac(processId: number): Promise<string[]> {
if (!hasAssistiveAccessOnMac) {
return [];
}
const script = `
const command = constructAppleScriptCommand(`
tell application "System Events"
try
set targetProcess to first process whose unix id is ${processId}
@@ -93,13 +94,8 @@ async function captureTitlesOnMac(processId: number): Promise<string[]> {
return allWindowNames
end tell
end tell
`;
const argument = script.trim()
.split(/[\r\n]+/)
.map((line) => `-e '${line.trim()}'`)
.join(' ');
const { stdout: titleOutput, error } = await runCommand(`osascript ${argument}`);
`);
const { stdout: titleOutput, error } = await runCommand(command);
if (error) {
let errorMessage = '';
if (error.includes('-25211')) {
@@ -116,3 +112,13 @@ async function captureTitlesOnMac(processId: number): Promise<string[]> {
}
return [title];
}
function constructAppleScriptCommand(appleScriptCode: string): string {
const scriptLines = splitTextIntoLines(appleScriptCode.trim());
const trimmedLines = scriptLines.map((line) => line.trim());
const nonEmptyLines = filterEmptyStrings(trimmedLines);
const formattedArguments = nonEmptyLines
.map((line) => `-e '${line.trim()}'`)
.join(' ');
return `osascript ${formattedArguments}`;
}

View File

@@ -1,4 +1,4 @@
import { indentText } from '@tests/shared/Text';
import { indentText } from '@/application/Common/Text/IndentText';
import { logCurrentArgs, CommandLineFlag, hasCommandLineFlag } from './cli-args';
import { log, die } from './utils/log';
import { ensureNpmProjectDir, npmInstall, npmBuild } from './utils/npm';

View File

@@ -1,5 +1,5 @@
import { exec } from 'child_process';
import { indentText } from '@tests/shared/Text';
import { indentText } from '@/application/Common/Text/IndentText';
import type { ExecOptions, ExecException } from 'child_process';
const TIMEOUT_IN_SECONDS = 180;

View File

@@ -1,5 +1,5 @@
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
import { indentText } from '@tests/shared/Text';
import { indentText } from '@/application/Common/Text/IndentText';
import { type UrlStatus, formatUrlStatus } from './UrlStatus';
const DefaultBaseRetryIntervalInMs = 5 /* sec */ * 1000;

View File

@@ -1,4 +1,4 @@
import { indentText } from '@tests/shared/Text';
import { indentText } from '@/application/Common/Text/IndentText';
import { fetchWithTimeout } from './FetchWithTimeout';
import { getDomainFromUrl } from './UrlDomainProcessing';

View File

@@ -1,4 +1,4 @@
import { indentText } from '@tests/shared/Text';
import { indentText } from '@/application/Common/Text/IndentText';
import { retryWithExponentialBackOff } from './ExponentialBackOffRetryHandler';
import { fetchFollow, type FollowOptions } from './FetchFollow';
import { getRandomUserAgent } from './UserAgents';

View File

@@ -19,7 +19,7 @@
import { constants } from 'crypto';
import tls from 'tls';
import { indentText } from '@tests/shared/Text';
import { indentText } from '@/application/Common/Text/IndentText';
export function randomizeTlsFingerprint() {
tls.DEFAULT_CIPHERS = getShuffledCiphers().join(':');

View File

@@ -1,4 +1,4 @@
import { indentText } from '@tests/shared/Text';
import { indentText } from '@/application/Common/Text/IndentText';
export interface UrlStatus {
readonly url: string;

View File

@@ -1,4 +1,4 @@
import { indentText } from '@tests/shared/Text';
import { indentText } from '@/application/Common/Text/IndentText';
export class TestExecutionDetailsLogger {
public logTestSectionStartDelimiter(): void {

View File

@@ -1,8 +1,8 @@
import { test, expect } from 'vitest';
import { parseApplication } from '@/application/Parser/ApplicationParser';
import { indentText } from '@tests/shared/Text';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { shuffle } from '@/application/Common/Shuffle';
import { indentText } from '@/application/Common/Text/IndentText';
import { type UrlStatus, formatUrlStatus } from './StatusChecker/UrlStatus';
import { getUrlStatusesInParallel, type BatchRequestOptions } from './StatusChecker/BatchStatusChecker';
import { TestExecutionDetailsLogger } from './TestExecutionDetailsLogger';

View File

@@ -1,5 +1,5 @@
import { indentText } from '@/application/Common/Text/IndentText';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { indentText } from '@tests/shared/Text';
/**
* Asserts that an array deeply includes a specified item by comparing JSON-serialized versions.

View File

@@ -1,29 +0,0 @@
import { isString } from '@/TypeHelpers';
export function indentText(
text: string,
indentLevel = 1,
): string {
validateText(text);
const indentation = '\t'.repeat(indentLevel);
return splitTextIntoLines(text)
.map((line) => (line ? `${indentation}${line}` : line))
.join('\n');
}
export function splitTextIntoLines(text: string): string[] {
validateText(text);
return text
.split(/[\r\n]+/);
}
export function filterEmpty(texts: readonly (string | undefined | null)[]): string[] {
return texts
.filter((title): title is string => Boolean(title));
}
function validateText(text: string): void {
if (!isString(text)) {
throw new Error(`text is not a string. It is: ${typeof text}\n${text}`);
}
}

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