diff --git a/spec/regression/index.test.ts b/spec/regression/index.test.ts index 6a5aff0..16aba9e 100644 --- a/spec/regression/index.test.ts +++ b/spec/regression/index.test.ts @@ -121,13 +121,19 @@ test('class method received', t => { t.notThrows(() => substitute.received(1).c('hi', 'the1re')) t.notThrows(() => substitute.received().c('hi', 'there')) - const expectedMessage = 'Expected 7 calls to the method c with arguments [\'hi\', \'there\'], but received 4 of such calls.\n' + - 'All calls received to method c:\n' + - '-> call with arguments [\'hi\', \'there\']\n' + - '-> call with arguments [\'hi\', \'the1re\']\n' + - '-> call with arguments [\'hi\', \'there\']\n' + - '-> call with arguments [\'hi\', \'there\']\n' + - '-> call with arguments [\'hi\', \'there\']' + const expectedMessage = 'Call count mismatch in @Substitute.c:\n' + + `Expected to receive 7 method calls matching c('hi', 'there'), but received 4.\n` + + 'All property or method calls to @Substitute.c received so far:\n' + + `› ✔ @Substitute.c('hi', 'there')\n` + + ` called at (${process.cwd()}/spec/regression/index.test.ts:114:18)\n` + + `› ✘ @Substitute.c('hi', 'the1re')\n` + + ` called at (${process.cwd()}/spec/regression/index.test.ts:115:18)\n` + + `› ✔ @Substitute.c('hi', 'there')\n` + + ` called at (${process.cwd()}/spec/regression/index.test.ts:116:18)\n` + + `› ✔ @Substitute.c('hi', 'there')\n` + + ` called at (${process.cwd()}/spec/regression/index.test.ts:117:18)\n` + + `› ✔ @Substitute.c('hi', 'there')\n` + + ` called at (${process.cwd()}/spec/regression/index.test.ts:118:18)\n` const { message } = t.throws(() => { substitute.received(7).c('hi', 'there') }) t.is(message.replace(textModifierRegex, ''), expectedMessage) }) @@ -142,9 +148,11 @@ test('received call matches after partial mocks using property instance mimicks' substitute.received(1).c('lala', 'bar') t.notThrows(() => substitute.received(1).c('lala', 'bar')) - const expectedMessage = 'Expected 2 calls to the method c with arguments [\'lala\', \'bar\'], but received 1 of such calls.\n' + - 'All calls received to method c:\n' + - '-> call with arguments [\'lala\', \'bar\']' + const expectedMessage = 'Call count mismatch in @Substitute.c:\n' + + `Expected to receive 2 method calls matching c('lala', 'bar'), but received 1.\n` + + 'All property or method calls to @Substitute.c received so far:\n' + + `› ✔ @Substitute.c('lala', 'bar')\n` + + ` called at (${process.cwd()}/spec/regression/index.test.ts:145:13)\n` const { message } = t.throws(() => substitute.received(2).c('lala', 'bar')) t.is(message.replace(textModifierRegex, ''), expectedMessage) t.deepEqual(substitute.d, 1337) diff --git a/src/SubstituteException.ts b/src/SubstituteException.ts index 4595a01..ac94466 100644 --- a/src/SubstituteException.ts +++ b/src/SubstituteException.ts @@ -1,49 +1,48 @@ -import { PropertyType } from './Types' -import { RecordedArguments } from './RecordedArguments' -import { PropertyType as PropertyTypeMap, stringifyArguments, stringifyCalls, textModifier, plurify } from './Utilities' +import { SubstituteNodeModel } from './Types' +import { stringifyReceivedCalls, TextBuilder, stringifyExpectation } from './Utilities' -enum SubstituteExceptionTypes { - CallCountMissMatch = 'CallCountMissMatch', - PropertyNotMocked = 'PropertyNotMocked' -} +const SubstituteExceptionType = { + callCountMismatch: 'CallCountMismatch', + PropertyNotMocked: 'PropertyNotMocked' +} as const +type SubstituteExceptionType = typeof SubstituteExceptionType[keyof typeof SubstituteExceptionType] export class SubstituteException extends Error { - type?: SubstituteExceptionTypes + type?: SubstituteExceptionType - constructor(msg: string, exceptionType?: SubstituteExceptionTypes) { + constructor(msg: string, exceptionType?: SubstituteExceptionType) { super(msg) - Error.captureStackTrace(this, SubstituteException) this.name = new.target.name this.type = exceptionType + const errorConstructor = exceptionType !== undefined ? SubstituteException[`for${exceptionType}`] : undefined + Error.captureStackTrace(this, errorConstructor) } - static forCallCountMissMatch( - count: { expected: number | undefined, received: number }, - property: { type: PropertyType, value: PropertyKey }, - calls: { expected: RecordedArguments, received: RecordedArguments[] } + static forCallCountMismatch( + expected: { count: number | undefined, call: SubstituteNodeModel }, + received: { matchCount: number, calls: SubstituteNodeModel[] } ) { - const propertyValue = textModifier.bold(property.value.toString()) - const commonMessage = `Expected ${textModifier.bold( - count.expected === undefined ? '1 or more' : count.expected.toString() - )} ${plurify('call', count.expected)} to the ${textModifier.italic(property.type)} ${propertyValue}` - - const messageForMethods = property.type === PropertyTypeMap.Method ? ` with ${stringifyArguments(calls.expected)}` : '' // should also apply for setters (instead of methods only) - const receivedMessage = `, but received ${textModifier.bold(count.received < 1 ? 'none' : count.received.toString())} of such calls.` - - const callTrace = calls.received.length > 0 - ? `\nAll calls received to ${textModifier.italic(property.type)} ${propertyValue}:${stringifyCalls(calls.received)}` - : '' - - return new this( - commonMessage + messageForMethods + receivedMessage + callTrace, - SubstituteExceptionTypes.CallCountMissMatch - ) + const callPath = `@Substitute.${expected.call.key.toString()}` + + const textBuilder = new TextBuilder() + .add('Call count mismatch in ') + .add('@Substitute.', t => t.underline()) + .add(expected.call.key.toString(), t => t.bold().underline()) + .add(':') + .newLine().add('Expected to receive ') + .addParts(...stringifyExpectation(expected)) + .add(', but received ') + .add(received.matchCount < 1 ? 'none' : received.matchCount.toString(), t => t.faint()) + .add('.') + if (received.calls.length > 0) textBuilder.newLine().add(`All property or method calls to ${callPath} received so far:${stringifyReceivedCalls(callPath, expected.call, received.calls)}`) + + return new this(textBuilder.toString(), SubstituteExceptionType.callCountMismatch) } static forPropertyNotMocked(property: PropertyKey) { return new this( `There is no mock for property: ${property.toString()}`, - SubstituteExceptionTypes.PropertyNotMocked + SubstituteExceptionType.PropertyNotMocked ) } diff --git a/src/Utilities.ts b/src/Utilities.ts index 87414f4..0d621e3 100644 --- a/src/Utilities.ts +++ b/src/Utilities.ts @@ -1,6 +1,6 @@ import { inspect } from 'util' import { RecordedArguments } from './RecordedArguments' -import type { AssertionMethod, ConfigurationMethod, SubstituteMethod, SubstitutionMethod } from './Types' +import type { AssertionMethod, ConfigurationMethod, SubstituteMethod, SubstitutionMethod, SubstituteNodeModel } from './Types' export const PropertyType = { Method: 'method', @@ -24,25 +24,145 @@ export const ClearType = { SubstituteValues: 'substituteValues' } as const -export const stringifyArguments = (args: RecordedArguments) => textModifier.faint( - args.hasArguments() ? - `arguments [${args.value.map(x => inspect(x, { colors: true })).join(', ')}]` : - 'no arguments' -) +const stringifyArguments = (args: RecordedArguments) => args.hasArguments() + ? `(${args.value.map(x => inspect(x, { colors: true })).join(', ')})` + : '' -export const stringifyCalls = (calls: RecordedArguments[]) => { - if (calls.length === 0) return ' (no calls)' +const matchBasedPrefix = (isMatch?: boolean) => { + switch (isMatch) { + case true: return '✔ ' + case false: return '✘ ' + default: return '' + } +} + +const matchBasedTextPartModifier = (isMatch?: boolean) => (part: TextPart) => isMatch === undefined + ? part + : isMatch + ? part.faint() + : part.bold() + +export const stringifyCall = (context: { callPath: string, expectedArguments?: RecordedArguments }) => { + return (call: SubstituteNodeModel): TextPart[] => { + const isMatch = context.expectedArguments?.match(call.recordedArguments) + const textBuilder = new TextBuilder() + .add(matchBasedPrefix(isMatch)) + .add(context.callPath) + .add(stringifyArguments(call.recordedArguments)) + if (call.stack !== undefined && isMatch !== undefined) textBuilder.newLine().add(call.stack.split('\n')[1].replace('at ', 'called at '), t => t.faint()) + return textBuilder.parts.map(matchBasedTextPartModifier(isMatch)) + } +} - const key = '\n-> call with ' - const callsDetails = calls.map(stringifyArguments) - return `${key}${callsDetails.join(key)}` +export const stringifyExpectation = (expected: { count: number | undefined, call: SubstituteNodeModel }) => { + const textBuilder = new TextBuilder() + textBuilder.add(expected.count === undefined ? '1 or more' : expected.count.toString(), t => t.bold()) + .add(' ') + .add(expected.call.propertyType, t => t.bold()) + .add(plurify(' call', expected.count), t => t.bold()) + .add(' matching ') + .addParts(...stringifyCall({ callPath: expected.call.key.toString() })(expected.call).map(t => t.bold())) + return textBuilder.parts +} + +const createKey = () => { + const textBuilder = new TextBuilder() + textBuilder.newLine().add('› ') + return textBuilder +} + +export const stringifyReceivedCalls = (callPath: string, expected: SubstituteNodeModel, received: SubstituteNodeModel[]) => { + const textBuilder = new TextBuilder() + const stringify = stringifyCall({ callPath, expectedArguments: expected.recordedArguments }) + received.forEach(receivedCall => textBuilder.addParts(...createKey().parts, ...stringify(receivedCall))) + return textBuilder.newLine().toString() } const baseTextModifier = (str: string, modifierStart: number, modifierEnd: number) => `\x1b[${modifierStart}m${str}\x1b[${modifierEnd}m` export const textModifier = { - bold: (str: string) => baseTextModifier(str, 1, 22), - faint: (str: string) => baseTextModifier(str, 2, 22), - italic: (str: string) => baseTextModifier(str, 3, 23) + bold: (str: string) => baseTextModifier(str, 1, 0), + faint: (str: string) => baseTextModifier(str, 2, 0), + italic: (str: string) => baseTextModifier(str, 3, 0) } -export const plurify = (str: string, count?: number) => `${str}${count === 1 ? '' : 's'}` \ No newline at end of file +const plurify = (str: string, count?: number) => `${str}${count === 1 ? '' : 's'}` + +class TextPart { + private _modifiers: string[] = [] + private readonly _value: string + constructor(valueOrInstance: string | TextPart) { + if (valueOrInstance instanceof TextPart) { + this._modifiers = [...valueOrInstance._modifiers] + this._value = valueOrInstance._value + } else this._value = valueOrInstance + } + + private baseTextModifier(modifier: number) { + return `\x1b[${modifier}m` + } + + public bold() { + this._modifiers.push(this.baseTextModifier(1)) + return this + } + + public faint() { + this._modifiers.push(this.baseTextModifier(2)) + return this + } + + public italic() { + this._modifiers.push(this.baseTextModifier(3)) + return this + } + + public underline() { + this._modifiers.push(this.baseTextModifier(4)) + return this + } + + public resetFormat(): void { + this._modifiers = [] + } + + public clone(): TextPart { + return new TextPart(this) + } + + toString() { + return this._modifiers.length > 0 ? `${this._modifiers.join('')}${this._value}\x1b[0m` : this._value + } +} + +export class TextBuilder { + private readonly _parts: TextPart[] = [] + + public add(text: string, texPartCb: (textPart: TextPart) => void = () => { }): this { + const textPart = new TextPart(text) + this._parts.push(textPart) + texPartCb(textPart) + return this + } + + public addParts(...textParts: TextPart[]): this { + this._parts.push(...textParts) + return this + } + + public newLine() { + this._parts.push(new TextPart('\n')) + return this + } + + public toString() { + return this._parts.join('') + } + + public clone() { + return new TextBuilder().addParts(...this._parts.map(x => x.clone())) + } + + public get parts() { + return this._parts + } +} \ No newline at end of file