371 lines
18 KiB
JavaScript
371 lines
18 KiB
JavaScript
/**
|
|
* process-quality.test.mjs — Systematic test suite for process-quality.mjs
|
|
*
|
|
* Tests every exported function across:
|
|
* - Markdown table parsing (well-formed, malformed, empty, header-only)
|
|
* - Friction tag extraction (bare tag, tag with reason, no tag, case sensitivity)
|
|
* - Aggregation (grouping, dedup, threshold filtering, sort order)
|
|
* - CLI behavior (args, flags, missing file)
|
|
*
|
|
* Run: node process-quality.test.mjs
|
|
*/
|
|
|
|
import { parseActiveInterviews, extractFriction, aggregateProcessQuality } from './process-quality.mjs';
|
|
import { execFileSync } from 'child_process';
|
|
import { join, dirname } from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
|
|
import { tmpdir } from 'os';
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
const failures = [];
|
|
|
|
function ok(label, cond) {
|
|
if (cond) {
|
|
passed++;
|
|
} else {
|
|
failed++;
|
|
failures.push(label);
|
|
console.log(` FAIL: ${label}`);
|
|
}
|
|
}
|
|
|
|
function eq(label, actual, expected) {
|
|
const a = JSON.stringify(actual);
|
|
const e = JSON.stringify(expected);
|
|
if (a === e) {
|
|
passed++;
|
|
} else {
|
|
failed++;
|
|
failures.push(label);
|
|
console.log(` FAIL: ${label}`);
|
|
console.log(` expected: ${e}`);
|
|
console.log(` actual: ${a}`);
|
|
}
|
|
}
|
|
|
|
function table(rows) {
|
|
const header = '| Company | Role | Round | Date/Time | Interviewer | Status | Notes |';
|
|
const sep = '|---------|------|-------|-----------|-------------|--------|-------|';
|
|
return [header, sep, ...rows].join('\n');
|
|
}
|
|
|
|
// ============================================================================
|
|
// 1. parseActiveInterviews — input validation
|
|
// ============================================================================
|
|
console.log('\n--- 1. parseActiveInterviews input validation ---');
|
|
|
|
eq('null input -> empty', parseActiveInterviews(null), []);
|
|
eq('undefined input -> empty', parseActiveInterviews(undefined), []);
|
|
eq('empty string -> empty', parseActiveInterviews(''), []);
|
|
eq('whitespace-only -> empty', parseActiveInterviews(' \n \n'), []);
|
|
eq('non-string input (number) -> empty', parseActiveInterviews(42), []);
|
|
eq('non-string input (object) -> empty', parseActiveInterviews({}), []);
|
|
eq('prose with no table -> empty', parseActiveInterviews('# Active Interviews\n\nNothing here yet.'), []);
|
|
eq('header only, no rows -> empty', parseActiveInterviews(table([])), []);
|
|
|
|
// ============================================================================
|
|
// 2. parseActiveInterviews — well-formed rows
|
|
// ============================================================================
|
|
console.log('\n--- 2. well-formed rows ---');
|
|
|
|
const basic = parseActiveInterviews(table([
|
|
'| Acme | Backend Engineer | Prescreen | 2026-06-01 | Jane | Scheduled | Clean process |',
|
|
]));
|
|
eq('parses 1 row', basic.length, 1);
|
|
eq('row has Company field', basic[0].Company, 'Acme');
|
|
eq('row has Role field', basic[0].Role, 'Backend Engineer');
|
|
eq('row has Notes field', basic[0].Notes, 'Clean process');
|
|
|
|
const multi = parseActiveInterviews(table([
|
|
'| Acme | Backend Engineer | Prescreen | 2026-06-01 | Jane | Scheduled | note 1 |',
|
|
'| Beta | Coordinator | Round 1 | 2026-06-08 | HM | Scheduled | note 2 |',
|
|
]));
|
|
eq('parses 2 rows', multi.length, 2);
|
|
|
|
// ============================================================================
|
|
// 3. parseActiveInterviews — malformed / edge-case rows
|
|
// ============================================================================
|
|
console.log('\n--- 3. malformed rows ---');
|
|
|
|
const withMalformed = parseActiveInterviews(table([
|
|
'| Acme | Backend Engineer | Prescreen | 2026-06-01 | Jane | Scheduled | ok |',
|
|
'| Too | Few | Cells |',
|
|
'| Beta | Coordinator | Round 1 | 2026-06-08 | HM | Scheduled | ok 2 |',
|
|
]));
|
|
eq('malformed (wrong column count) row is dropped, valid rows kept', withMalformed.length, 2);
|
|
|
|
const preambleAndTable = parseActiveInterviews(
|
|
'# Active Interviews\n\nSome free text here.\n\n' + table([
|
|
'| Acme | Backend Engineer | Prescreen | 2026-06-01 | Jane | Scheduled | ok |',
|
|
])
|
|
);
|
|
eq('non-table prose lines before the table are ignored', preambleAndTable.length, 1);
|
|
|
|
const noSeparator = parseActiveInterviews(
|
|
'| Company | Role | Round | Date/Time | Interviewer | Status | Notes |\n' +
|
|
'| Acme | Backend Engineer | Prescreen | 2026-06-01 | Jane | Scheduled | ok |'
|
|
);
|
|
ok('table without a separator row still parses the data row', noSeparator.length === 1);
|
|
|
|
// A second, unrelated markdown table later in the document must not be
|
|
// merged into the parsed rows — only the first contiguous table block is
|
|
// scoped as "the" table (CodeRabbit review, PR #1467).
|
|
const twoTables = parseActiveInterviews(
|
|
table(['| Acme | Backend Engineer | Prescreen | 2026-06-01 | Jane | Scheduled | ok |']) +
|
|
'\n\nSome unrelated section.\n\n' +
|
|
'| Foo | Bar |\n|-----|-----|\n| unrelated | table |\n'
|
|
);
|
|
eq('second markdown table later in the document is not merged in', twoTables.length, 1);
|
|
ok('second-table columns (Foo/Bar) never leak into the result', !('Foo' in twoTables[0]));
|
|
|
|
// A blank-line gap between two table blocks still stops at the first block
|
|
// (blank lines are not pipe-formatted, so they naturally break contiguity).
|
|
const gappedTables = parseActiveInterviews(
|
|
table(['| Acme | Backend Engineer | Prescreen | 2026-06-01 | Jane | Scheduled | ok |']) +
|
|
'\n\n' +
|
|
table(['| Beta | Coordinator | Round 1 | 2026-06-08 | HM | Scheduled | ok 2 |'])
|
|
);
|
|
eq('a gapped second table block is excluded, only first block parsed', gappedTables.length, 1);
|
|
eq('first block company preserved', gappedTables[0].Company, 'Acme');
|
|
|
|
// ============================================================================
|
|
// 4. extractFriction
|
|
// ============================================================================
|
|
console.log('\n--- 4. extractFriction ---');
|
|
|
|
eq('null row -> no friction', extractFriction(null), { hasFriction: false, reason: '' });
|
|
eq('undefined row -> no friction', extractFriction(undefined), { hasFriction: false, reason: '' });
|
|
eq('row with no Notes key -> no friction', extractFriction({ Company: 'Acme' }), { hasFriction: false, reason: '' });
|
|
eq('empty Notes -> no friction', extractFriction({ Notes: '' }), { hasFriction: false, reason: '' });
|
|
eq('Notes without tag -> no friction', extractFriction({ Notes: 'Everything went smoothly' }), { hasFriction: false, reason: '' });
|
|
|
|
eq('bare tag -> friction, no reason', extractFriction({ Notes: '[process-friction]' }), { hasFriction: true, reason: '' });
|
|
eq('tag with reason -> friction + reason', extractFriction({ Notes: '[process-friction: recruiter overrode stated availability]' }),
|
|
{ hasFriction: true, reason: 'recruiter overrode stated availability' });
|
|
eq('tag embedded mid-sentence', extractFriction({ Notes: 'Confirmed Wed 2pm. [process-friction: took 3 rounds to land a time] Prep done.' }),
|
|
{ hasFriction: true, reason: 'took 3 rounds to land a time' });
|
|
eq('tag is case-insensitive', extractFriction({ Notes: '[PROCESS-FRICTION: caps test]' }),
|
|
{ hasFriction: true, reason: 'caps test' });
|
|
eq('tag lookup is case-insensitive on the Notes key', extractFriction({ notes: '[process-friction: lowercase key]' }),
|
|
{ hasFriction: true, reason: 'lowercase key' });
|
|
eq('reason with internal whitespace is trimmed', extractFriction({ Notes: '[process-friction: padded reason ]' }),
|
|
{ hasFriction: true, reason: 'padded reason' });
|
|
|
|
// ============================================================================
|
|
// 5. aggregateProcessQuality — input validation
|
|
// ============================================================================
|
|
console.log('\n--- 5. aggregateProcessQuality input validation ---');
|
|
|
|
eq('null input -> empty', aggregateProcessQuality(null), []);
|
|
eq('undefined input -> empty', aggregateProcessQuality(undefined), []);
|
|
eq('empty array -> empty', aggregateProcessQuality([]), []);
|
|
eq('rows missing Company field are skipped', aggregateProcessQuality([{ Notes: 'x' }, null, undefined]), []);
|
|
eq('row with empty Company is skipped', aggregateProcessQuality([{ Company: '', Notes: 'x' }]), []);
|
|
eq('row with whitespace-only Company is skipped', aggregateProcessQuality([{ Company: ' ', Notes: 'x' }]), []);
|
|
|
|
// ============================================================================
|
|
// 6. aggregateProcessQuality — grouping and counting
|
|
// ============================================================================
|
|
console.log('\n--- 6. grouping and counting ---');
|
|
|
|
const rows = [
|
|
{ Company: 'Acme', Notes: 'no issue' },
|
|
{ Company: 'Acme', Notes: '[process-friction: reason A]' },
|
|
{ Company: 'acme', Notes: 'no issue either' }, // case-insensitive grouping
|
|
{ Company: 'Beta', Notes: '[process-friction]' },
|
|
];
|
|
const agg = aggregateProcessQuality(rows, 1);
|
|
|
|
const acmeSignal = agg.find(s => s.company.toLowerCase() === 'acme');
|
|
ok('Acme/acme grouped into a single entry', !!acmeSignal);
|
|
if (acmeSignal) {
|
|
eq('Acme total interviews = 3 (case-insensitive dedup)', acmeSignal.totalInterviews, 3);
|
|
eq('Acme friction count = 1', acmeSignal.frictionCount, 1);
|
|
eq('Acme friction rate = 0.33', acmeSignal.frictionRate, 0.33);
|
|
eq('Acme reasons = ["reason A"]', acmeSignal.reasons, ['reason A']);
|
|
}
|
|
|
|
const betaSignal = agg.find(s => s.company === 'Beta');
|
|
ok('Beta present', !!betaSignal);
|
|
if (betaSignal) {
|
|
eq('Beta total interviews = 1', betaSignal.totalInterviews, 1);
|
|
eq('Beta friction count = 1', betaSignal.frictionCount, 1);
|
|
eq('Beta friction rate = 1', betaSignal.frictionRate, 1);
|
|
eq('Beta reasons empty (bare tag)', betaSignal.reasons, []);
|
|
}
|
|
|
|
// Company name in output preserves the first-seen casing.
|
|
eq('output company name preserves first-seen casing', acmeSignal.company, 'Acme');
|
|
|
|
// ============================================================================
|
|
// 7. aggregateProcessQuality — threshold filtering
|
|
// ============================================================================
|
|
console.log('\n--- 7. threshold filtering ---');
|
|
|
|
const thresholdRows = [
|
|
{ Company: 'OneShot', Notes: '[process-friction]' },
|
|
{ Company: 'TwoShot', Notes: '[process-friction]' },
|
|
{ Company: 'TwoShot', Notes: 'fine' },
|
|
];
|
|
|
|
eq('threshold=1: both companies included', aggregateProcessQuality(thresholdRows, 1).map(s => s.company).sort(), ['OneShot', 'TwoShot']);
|
|
eq('threshold=2: only TwoShot included', aggregateProcessQuality(thresholdRows, 2).map(s => s.company), ['TwoShot']);
|
|
eq('threshold=3: neither included', aggregateProcessQuality(thresholdRows, 3), []);
|
|
eq('negative threshold falls back to 1', aggregateProcessQuality(thresholdRows, -5).map(s => s.company).sort(), ['OneShot', 'TwoShot']);
|
|
eq('non-numeric threshold falls back to 1', aggregateProcessQuality(thresholdRows, NaN).map(s => s.company).sort(), ['OneShot', 'TwoShot']);
|
|
eq('threshold=0: all companies included', aggregateProcessQuality(thresholdRows, 0).map(s => s.company).sort(), ['OneShot', 'TwoShot']);
|
|
|
|
// ============================================================================
|
|
// 8. aggregateProcessQuality — sort order
|
|
// ============================================================================
|
|
console.log('\n--- 8. sort order ---');
|
|
|
|
const sortRows = [
|
|
{ Company: 'LowFriction', Notes: '[process-friction]' },
|
|
{ Company: 'LowFriction', Notes: 'fine' },
|
|
{ Company: 'LowFriction', Notes: 'fine' },
|
|
{ Company: 'LowFriction', Notes: 'fine' },
|
|
{ Company: 'HighFriction', Notes: '[process-friction]' },
|
|
{ Company: 'HighFriction', Notes: '[process-friction]' },
|
|
{ Company: 'HighFriction', Notes: '[process-friction]' },
|
|
{ Company: 'NoFriction', Notes: 'fine' },
|
|
{ Company: 'AlphabeticalTieA', Notes: '[process-friction]' },
|
|
{ Company: 'AlphabeticalTieB', Notes: '[process-friction]' },
|
|
];
|
|
const sorted = aggregateProcessQuality(sortRows, 1);
|
|
eq('sorted by frictionCount descending first', sorted[0].company, 'HighFriction');
|
|
ok('NoFriction sorts last (frictionCount 0)', sorted[sorted.length - 1].company === 'NoFriction');
|
|
|
|
// Alphabetical tie-break when frictionCount and frictionRate are equal.
|
|
const tieIdxA = sorted.findIndex(s => s.company === 'AlphabeticalTieA');
|
|
const tieIdxB = sorted.findIndex(s => s.company === 'AlphabeticalTieB');
|
|
ok('alphabetical tie-break: TieA before TieB', tieIdxA < tieIdxB);
|
|
|
|
// ============================================================================
|
|
// 9. Integration — full markdown round-trip
|
|
// ============================================================================
|
|
console.log('\n--- 9. integration round-trip ---');
|
|
|
|
const fullMd = table([
|
|
'| Sun Life | Regional Coordinator | Prescreen | 2026-07-08 | Sasha (bot) | Scheduled | [process-friction: stated availability overridden across multiple rounds] |',
|
|
'| Sun Life | Digital Employee Experience | Prescreen | 2026-06-16 | Recruiter | Rejected | clean process |',
|
|
'| Rentsync | Onboarding Specialist | Intro Call | 2026-06-25 | Evan | Scheduled | clean process |',
|
|
]);
|
|
const fullRows = parseActiveInterviews(fullMd);
|
|
const fullSignals = aggregateProcessQuality(fullRows, 1);
|
|
|
|
eq('round-trip: 3 rows parsed', fullRows.length, 3);
|
|
const sunLife = fullSignals.find(s => s.company === 'Sun Life');
|
|
ok('round-trip: Sun Life signal present', !!sunLife);
|
|
if (sunLife) {
|
|
eq('round-trip: Sun Life total = 2', sunLife.totalInterviews, 2);
|
|
eq('round-trip: Sun Life friction = 1', sunLife.frictionCount, 1);
|
|
eq('round-trip: Sun Life reason captured', sunLife.reasons[0], 'stated availability overridden across multiple rounds');
|
|
}
|
|
const rentsync = fullSignals.find(s => s.company === 'Rentsync');
|
|
ok('round-trip: Rentsync signal present', !!rentsync);
|
|
if (rentsync) {
|
|
eq('round-trip: Rentsync friction = 0', rentsync.frictionCount, 0);
|
|
}
|
|
|
|
// ============================================================================
|
|
// 10. CLI behavior
|
|
// ============================================================================
|
|
console.log('\n--- 10. CLI behavior ---');
|
|
|
|
const scriptPath = join(dirname(fileURLToPath(import.meta.url)), 'process-quality.mjs');
|
|
|
|
try {
|
|
execFileSync('node', [scriptPath, '--self-test'], { encoding: 'utf-8', timeout: 10000 });
|
|
ok('--self-test exits 0', true);
|
|
} catch (e) {
|
|
ok('--self-test exits 0', false);
|
|
console.log(` exit code: ${e.status}, stderr: ${e.stderr?.slice(0, 200)}`);
|
|
}
|
|
|
|
const defaultOut = execFileSync('node', [scriptPath], {
|
|
encoding: 'utf-8', timeout: 10000,
|
|
cwd: dirname(scriptPath),
|
|
});
|
|
const defaultJson = JSON.parse(defaultOut);
|
|
ok('default produces valid JSON', typeof defaultJson === 'object');
|
|
ok('default has metadata', 'metadata' in defaultJson);
|
|
ok('default has signals array', 'signals' in defaultJson && Array.isArray(defaultJson.signals));
|
|
eq('default minThreshold = 1', defaultJson.metadata.minThreshold, 1);
|
|
|
|
// Missing-file CLI behavior, isolated via --file pointing at a path that
|
|
// deliberately does not exist inside a fresh temp dir. This must NOT depend
|
|
// on whether the caller's real data/active-interviews.md happens to exist —
|
|
// a contributor running this suite with real interview data in their own
|
|
// workspace must get the same result as CI.
|
|
const missingFileTmpDir = mkdtempSync(join(tmpdir(), 'process-quality-missing-'));
|
|
const missingFilePath = join(missingFileTmpDir, 'does-not-exist.md');
|
|
try {
|
|
const missingOut = execFileSync('node', [scriptPath, '--file', missingFilePath], {
|
|
encoding: 'utf-8', timeout: 10000,
|
|
cwd: dirname(scriptPath),
|
|
});
|
|
ok('missing --file path: CLI does not throw', !!missingOut);
|
|
const missingJson = JSON.parse(missingOut);
|
|
eq('missing --file path: totalRows = 0', missingJson.metadata.totalRows, 0);
|
|
eq('missing --file path: signals = []', missingJson.signals, []);
|
|
} finally {
|
|
rmSync(missingFileTmpDir, { recursive: true, force: true });
|
|
}
|
|
|
|
// --file also works as a positive-path override: point it at a controlled
|
|
// fixture file and confirm the CLI reads exactly that file, isolated from
|
|
// whatever the caller's real workspace contains.
|
|
const fixtureTmpDir = mkdtempSync(join(tmpdir(), 'process-quality-fixture-'));
|
|
const fixturePath = join(fixtureTmpDir, 'active-interviews.md');
|
|
try {
|
|
writeFileSync(fixturePath, table([
|
|
'| FixtureCo | Role | Prescreen | 2026-07-01 | Someone | Scheduled | [process-friction: fixture reason] |',
|
|
]));
|
|
const fixtureOut = execFileSync('node', [scriptPath, '--file', fixturePath], {
|
|
encoding: 'utf-8', timeout: 10000,
|
|
cwd: dirname(scriptPath),
|
|
});
|
|
const fixtureJson = JSON.parse(fixtureOut);
|
|
eq('--file fixture: totalRows = 1', fixtureJson.metadata.totalRows, 1);
|
|
eq('--file fixture: 1 signal (FixtureCo)', fixtureJson.signals.length, 1);
|
|
eq('--file fixture: FixtureCo friction captured', fixtureJson.signals[0]?.reasons?.[0], 'fixture reason');
|
|
} finally {
|
|
rmSync(fixtureTmpDir, { recursive: true, force: true });
|
|
}
|
|
|
|
const thresholdOut = execFileSync('node', [scriptPath, '--min-threshold', '3'], {
|
|
encoding: 'utf-8', timeout: 10000,
|
|
cwd: dirname(scriptPath),
|
|
});
|
|
const thresholdJson = JSON.parse(thresholdOut);
|
|
eq('--min-threshold sets minThreshold in metadata', thresholdJson.metadata.minThreshold, 3);
|
|
|
|
const badThresholdOut = execFileSync('node', [scriptPath, '--min-threshold', 'abc'], {
|
|
encoding: 'utf-8', timeout: 10000,
|
|
cwd: dirname(scriptPath),
|
|
});
|
|
const badThresholdJson = JSON.parse(badThresholdOut);
|
|
eq('--min-threshold abc falls back to 1', badThresholdJson.metadata.minThreshold, 1);
|
|
|
|
const summaryOut = execFileSync('node', [scriptPath, '--summary'], {
|
|
encoding: 'utf-8', timeout: 10000,
|
|
cwd: dirname(scriptPath),
|
|
});
|
|
ok('--summary produces human-readable output', summaryOut.includes('Process Quality Signal'));
|
|
|
|
// ============================================================================
|
|
// RESULTS
|
|
// ============================================================================
|
|
console.log(`\n${'='.repeat(78)}`);
|
|
console.log(` Results: ${passed} passed, ${failed} failed`);
|
|
if (failed > 0) {
|
|
console.log(`\n Failed tests:`);
|
|
for (const f of failures) console.log(` - ${f}`);
|
|
}
|
|
console.log(`${'='.repeat(78)}`);
|
|
|
|
process.exit(failed > 0 ? 1 : 0);
|