Files
santifer--career-ops/process-quality.test.mjs
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

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