@plugjs/expect5
Version:
Unit Testing for the PlugJS Build System ========================================
438 lines (382 loc) • 13.7 kB
text/typescript
/* eslint-disable unicorn/no-instanceof-builtins */
import { $grn, $gry, $red, $und, $wht, $ylw } from '@plugjs/plug/logging'
import { textDiff } from '@plugjs/plug/utils'
import { isMatcher, stringifyValue } from './types'
import type { Logger } from '@plugjs/plug/logging'
import type {
Diff,
ExpectedDiff,
ExtraValueDiff,
MissingValueDiff,
ObjectDiff,
} from './diff'
/* ========================================================================== *
* CONSTANT LABELS FOR PRINTING *
* ========================================================================== */
const _opnPar = $gry('(')
const _clsPar = $gry(')')
const _opnCrl = $gry('{')
const _clsCrl = $gry('}')
const _curls = $gry('{}')
const _opnSqr = $gry('[')
const _clsSqr = $gry(']')
const _squares = $gry('[]')
const _slash = $gry('/')
const _tilde = $gry('~')
const _error = `${_opnPar}${$gry($und('error'))}${_clsPar}`
const _string = `${_opnPar}${$gry($und('string'))}${_clsPar}`
const _matcher = $gry('\u2026 matcher \u2026')
const _extraProps = $gry('\u2026 extra props \u2026')
const _diffHeader = `${$wht('Differences')} ${_opnPar}${$red('actual')}${_slash}${$grn('expected')}${_slash}${$ylw('errors')}${_clsPar}:`
/* ========================================================================== *
* PRINT DEPENDING ON DIFF TYPE *
* ========================================================================== */
function printBaseDiff(
log: Logger,
diff: Diff,
prop: string,
mapping: boolean,
comma: boolean,
): void {
if ('props' in diff) return printObjectDiff(log, diff, prop, mapping, comma)
if ('values' in diff) return printObjectDiff(log, diff, prop, mapping, comma)
if ('mappings' in diff) return printObjectDiff(log, diff, prop, mapping, comma)
if ('expected' in diff) return printExpectedDiff(log, diff, prop, mapping, comma)
if ('missing' in diff) return printMissingDiff(log, diff, prop, mapping, comma)
if ('extra' in diff) return printExtraDiff(log, diff, prop, mapping, comma)
const { prefix, suffix } =
diff.error ? // default style if error is the only property
fixups(prop, mapping, comma, diff.error) :
diff.diff ? // label as "differs" if no error was found
fixups(prop, mapping, comma, diff.error, $red, 'differs') :
fixups(prop, mapping, comma, diff.error)
dump(log, diff.value, prefix, suffix, diff.diff ? $red : $wht)
}
/* ========================================================================== */
function formatString(
value: string,
color: ((string: string) => string),
): string {
// determine the color of each character
const characters: [ (string: string) => string, string ][] = []
for (let i = 0; i < value.length; i ++) {
const c = value.charCodeAt(i)
if (c === 0x20) { // space
characters.push([ $gry, '\u00b7' ])
} else if (c === 0x09) { // tab
characters.push([ $gry, ' \u2192 ' ])
} else if (c < 0x10) { // control characters (1 hex digit)
characters.push([ $gry, `\\0${c.toString(16).toUpperCase()}` ])
} else if (c < 0x20) { // control characters (2 hex digits)
characters.push([ $gry, `\\${c.toString(16).toUpperCase()}` ])
} else if ((c >= 0x7F) && (c <= 0xA0)) { // del and non-printable latin 1
characters.push([ $gry, `\\${c.toString(16).toUpperCase()}` ])
} else { // anything else
characters.push([ color, value[i]! ])
}
}
// group characters by color
const chunks = characters.reduce((acc, [ color, c ]) => {
const prev = acc[acc.length - 1]
if (prev?.[0] === color) prev[1] += c
else acc.push([ color, c ])
return acc
}, [] as typeof characters)
// join chunks together colorizing each one
return chunks.map(([ color, c ]) => color(c)).join('')
}
function printExpectedDiff(
log: Logger,
diff: ExpectedDiff,
prop: string,
mapping: boolean,
comma: boolean,
): void {
// two different strings get a special treatment: a proper "diff"
if ((typeof diff.value === 'string') && (typeof diff.expected === 'string')) {
const { prefix, suffix } = fixups(prop, mapping, false, diff.error)
log.warn(`${prefix}${_string}${suffix}`)
log.warn(textDiff(
diff.value,
diff.expected,
(add: string) => ` ${$gry('+')} ${formatString(add, $grn)}`,
(del: string) => ` ${$gry('-')} ${formatString(del, $red)}`,
(txt: string) => ` ${formatString(txt, (s) => s)}`))
// if "value" is not an object (can fit on one line) we use it as prefix
} else if ((diff.value === null) || (typeof diff.value !== 'object')) {
const { prefix, suffix } = fixups(prop, mapping, comma, diff.error)
const joined = `${prefix}${$red(stringify(diff.value))} ${_tilde} `
dump(log, diff.expected, joined, suffix, $grn)
// if "expected" is not an object (can fit on one line) we use it as suffix
} else if ((diff.expected === null) || (typeof diff.expected !== 'object')) {
const { prefix, suffix } = fixups(prop, mapping, comma, diff.error)
const joined = ` ${_tilde} ${$grn(stringify(diff.expected))}${suffix}`
dump(log, diff.value, prefix, joined, $red)
// both "value" and "expected" are objects, so, we join them with a ~
} else {
// here the error _only_ goes on the last line...
const { prefix, suffix: suffix1 } = fixups(prop, mapping, false, '')
const { suffix: suffix2 } = fixups(prop, mapping, comma, diff.error)
const lastLine = dumpAndContinue(log, diff.expected, prefix, suffix1, $red)
dump(log, diff.value, `${lastLine} ${_tilde} `, suffix2, $grn)
}
}
/* ========================================================================== */
function printMissingDiff(
log: Logger,
diff: MissingValueDiff,
prop: string,
mapping: boolean,
comma: boolean,
): void {
const { prefix, suffix } = fixups(prop, mapping, comma, diff.error, $red, 'missing')
dump(log, diff.missing, prefix, suffix, $red)
}
/* ========================================================================== */
function printExtraDiff(
log: Logger,
diff: ExtraValueDiff,
prop: string,
mapping: boolean,
comma: boolean,
): void {
const { prefix, suffix } = fixups(prop, mapping, comma, diff.error, $red, 'extra')
dump(log, diff.extra, prefix, suffix, $red)
}
/* ========================================================================== */
function printObjectDiff(
log: Logger,
diff: ObjectDiff,
prop: string,
mapping: boolean,
comma: boolean,
): void {
const { prefix, suffix } = fixups(prop, mapping, comma, diff.error)
// prepare for deep inspection
const value = diff.value
const ctor = Object.getPrototypeOf(value)?.constructor
const string = (ctor === Object) || (ctor === Array) ? '' : stringifyValue(value)
// prepare first line of output
let line = string ? `${prefix}${$wht(string)} ` : prefix
let marked = false
// arrays or sets
if (diff.values) {
if (diff.values.length === 0) {
line = `${line}${_squares}`
} else {
log.warn(`${line}${_opnSqr}`)
log.enter()
try {
for (const subdiff of diff.values) {
printBaseDiff(log, subdiff, '', false, true)
}
} finally {
log.leave()
}
line = _clsSqr
}
marked = true
// values and mappings (arrays/sets and maps) are mutually exclusive
} else if (diff.mappings) {
if (Object.keys(diff.mappings).length === 0) {
line = `${line}${_curls}`
} else {
log.warn(`${line}${_opnCrl}`)
log.enter()
try {
for (const [ key, subdiff ] of diff.mappings) {
printBaseDiff(log, subdiff, stringifyValue(key), true, true)
}
} finally {
log.leave()
}
line = _clsCrl
}
marked = true
}
// extra properties
if (diff.props) {
if (marked) line = `${line} ${_extraProps} `
if (Object.keys(diff.props).length === 0) {
line = `${line}${_curls}`
} else {
log.warn(`${line}${_opnCrl}`)
log.enter()
try {
for (const [ prop, subdiff ] of Object.entries(diff.props)) {
printBaseDiff(log, subdiff, stringifyValue(prop), false, true)
}
} finally {
log.leave()
}
line = _clsCrl
}
marked = true
}
log.warn(`${line}${suffix}`)
}
/* ========================================================================== *
* PRINT HELPERS *
* ========================================================================== */
function stringify(
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
value: null | undefined | string | number | boolean | bigint | symbol | Function,
): string {
if (typeof value === 'string') return JSON.stringify(value)
return stringifyValue(value)
}
function fixups(
prop: string,
mapping: boolean,
comma: boolean,
error: string | undefined,
color?: ((string: string) => string) | undefined,
label?: string,
): { prefix: string, suffix: string } {
if (error) color = color || $ylw
const lbl = label ? `${_opnPar}${$gry($und(label))}${_clsPar} ` : ''
const sep = mapping ? ' => ': ': '
const prefix = prop ?
color ?
`${$gry(lbl)}${color(prop)}${$gry(sep)}` :
`${$gry(lbl)}${prop}${$gry(sep)}` :
label ?
`${$gry(lbl)}` :
''
error = error ? ` ${_error} ${$ylw(error)}` : ''
const suffix = `${comma ? $gry(',') : ''}${error}`
return { prefix, suffix }
}
function dump(
log: Logger,
value: any,
prefix: string,
suffix: string,
color: (string: string) => string,
stack: any[] = [],
): void {
log.warn(dumpAndContinue(log, value, prefix, suffix, color, stack))
}
function dumpAndContinue(
log: Logger,
value: any,
prefix: string,
suffix: string,
color: (string: string) => string,
stack: any[] = [],
): string {
// primitives just get dumped
if ((value === null) || (typeof value !== 'object')) {
return `${prefix}${color(stringify(value))}${suffix}`
}
// matchers are a very special value...
if (isMatcher(value)) {
return `${prefix}${_matcher}${suffix}`
}
// check for circular dependencies
const circular = stack.indexOf(value)
if (circular >= 0) {
return `${prefix}${$gry($und(`<circular ${circular}>`))}${suffix}`
}
// prepare for deep inspection
const ctor = Object.getPrototypeOf(value)?.constructor
const string = (ctor === Object) || (ctor === Array) ? '' : stringifyValue(value)
const keys = new Set(Object.keys(value))
// prepare first line of output
let line = string ? `${prefix}${color(string)} ` : prefix
let marked = false
// arrays (will remove keys for properties)
if (Array.isArray(value)) {
if (value.length === 0) {
line = `${line}${_squares}`
} else {
log.warn(`${line}${_opnSqr}`)
log.enter()
try {
for (let i = 0; i < value.length; i ++) {
const { prefix, suffix } = fixups('', false, true, undefined, color)
dump(log, value[i], prefix, suffix, color, [ ...stack, value ])
keys.delete(String(i))
}
} finally {
log.leave()
}
line = _clsSqr
}
marked = true
// arrays, sets and maps are mutually exclusive...
} else if (value instanceof Set) {
if (value.size === 0) {
line = `${line}${_squares}`
} else {
log.warn(`${line}${_opnSqr}`)
log.enter()
try {
const { prefix, suffix } = fixups('', false, true, undefined, color)
value.forEach((v) => dump(log, v, prefix, suffix, color, [ ...stack, value ]))
} finally {
log.leave()
}
line = _clsSqr
}
marked = true
// arrays, sets and maps are mutually exclusive...
} else if (value instanceof Map) {
if (value.size === 0) {
line = `${line}${_curls}`
} else {
log.warn(`${line}${_opnCrl}`)
log.enter()
try {
for (const [ key, subvalue ] of value) {
const { prefix, suffix } = fixups(stringifyValue(key), true, true, undefined, color)
dump(log, subvalue, prefix, suffix, color, [ ...stack, value ])
}
} finally {
log.leave()
}
line = _clsCrl
}
marked = true
}
// boxed strings leave props around
if (value instanceof String) {
const length = value.valueOf().length
for (let i = 0; i < length; i ++) keys.delete(String(i))
}
// extra properties might appear at any time...
if (keys.size) {
if (marked) line = `${line} ${_extraProps} `
log.warn(`${line}${_opnCrl}`)
log.enter()
try {
for (const key of keys) {
const { prefix, suffix } = fixups(stringifyValue(key), false, true, undefined, color)
dump(log, value[key], prefix, suffix, color, [ ...stack, value ])
}
} finally {
log.leave()
}
line = _clsCrl
marked = true
}
if (marked) {
return `${line}${suffix}`
} else {
return `${line}${_curls}${suffix}`
}
}
/* ========================================================================== *
* EXPORTSD *
* ========================================================================== */
/** Print a {@link Diff} to a log, with a nice header by default... */
export function printDiff(log: Logger, diff: Diff, header = true): void {
if (! header) return printBaseDiff(log, diff, '', false, false)
log.warn(_diffHeader)
log.enter()
try {
printBaseDiff(log, diff, '', false, false)
} finally {
log.leave()
}
}