Skip to content

Commit

Permalink
add textBuilder and improve assertion messages
Browse files Browse the repository at this point in the history
  • Loading branch information
notanengineercom committed Mar 9, 2024
1 parent d40a66b commit 0c3b8fd
Show file tree
Hide file tree
Showing 3 changed files with 183 additions and 56 deletions.
28 changes: 18 additions & 10 deletions spec/regression/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <anonymous> (${process.cwd()}/spec/regression/index.test.ts:114:18)\n` +
`› ✘ @Substitute.c('hi', 'the1re')\n` +
` called at <anonymous> (${process.cwd()}/spec/regression/index.test.ts:115:18)\n` +
`› ✔ @Substitute.c('hi', 'there')\n` +
` called at <anonymous> (${process.cwd()}/spec/regression/index.test.ts:116:18)\n` +
`› ✔ @Substitute.c('hi', 'there')\n` +
` called at <anonymous> (${process.cwd()}/spec/regression/index.test.ts:117:18)\n` +
`› ✔ @Substitute.c('hi', 'there')\n` +
` called at <anonymous> (${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)
})
Expand All @@ -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 <anonymous> (${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)
Expand Down
61 changes: 30 additions & 31 deletions src/SubstituteException.ts
Original file line number Diff line number Diff line change
@@ -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
)
}

Expand Down
150 changes: 135 additions & 15 deletions src/Utilities.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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'}`
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
}
}

0 comments on commit 0c3b8fd

Please sign in to comment.