95 lines
4.6 KiB
JavaScript
95 lines
4.6 KiB
JavaScript
/**
|
|
* invite-match.test.mjs — regression tests for invite-match.mjs's ambiguous-
|
|
* match ranking, which is the part most likely to silently regress: a wrong
|
|
* top candidate is worse than no candidate at all.
|
|
*
|
|
* Run: node invite-match.test.mjs
|
|
*/
|
|
|
|
import { matchInvite, normalizeCompanyName } from './invite-match.mjs';
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
const failures = [];
|
|
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
const rows = [
|
|
{ num: 201, company: 'Northwind Traders', role: 'Ops Coordinator', status: 'Applied', date: '2026-05-01', notes: '' },
|
|
{ num: 202, company: 'Northwind Traders', role: 'HR Assistant', status: 'Interview', date: '2026-05-15', notes: '' },
|
|
{ num: 203, company: 'Northwind Traders', role: 'Analyst', status: 'Rejected', date: '2026-04-10', notes: 'Rejected 2026-04-20' },
|
|
];
|
|
|
|
// Three tracker rows for the same company at three different statuses — the
|
|
// Interview row must outrank both Applied and Rejected, since an in-progress
|
|
// interview is the most likely thing a new invite email is about.
|
|
const result = matchInvite({ company: 'Northwind Traders', date: null, reqId: null }, rows);
|
|
eq('all three same-company candidates are returned, not just the top one', result.length, 3);
|
|
eq('Interview-status row ranks first among same-name candidates', result[0].appNumber, 202);
|
|
eq('Rejected-status row ranks last among same-name candidates', result[result.length - 1].appNumber, 203);
|
|
|
|
// A company name that only partially overlaps (e.g. recruiter drops a
|
|
// division name) must still resolve, but must not outrank an exact match
|
|
// when both are present in the tracker.
|
|
const mixedRows = [
|
|
...rows,
|
|
{ num: 204, company: 'Northwind', role: 'Coordinator', status: 'Applied', date: '2026-06-01', notes: '' },
|
|
];
|
|
const partial = matchInvite({ company: 'Northwind', date: null, reqId: null }, mixedRows);
|
|
eq('exact "Northwind" match outranks the longer "Northwind Traders" partial matches', partial[0].appNumber, 204);
|
|
|
|
// normalizeCompanyName must be idempotent — normalizing an already-normalized
|
|
// string must return it unchanged, otherwise repeated normalization could
|
|
// drift the matching key across call sites.
|
|
const once = normalizeCompanyName('Acme Technologies Inc.');
|
|
eq('normalizeCompanyName is idempotent', normalizeCompanyName(once), once);
|
|
|
|
// A req ID that appears verbatim in a row's notes must outrank a same-name
|
|
// row without it, even though both have identical name similarity — this is
|
|
// the strongest disambiguation signal the matcher has, so it must actually
|
|
// move the ranking, not just add a negligible tiebreaker.
|
|
const reqIdRows = [
|
|
{ num: 301, company: 'Fabrikam', role: 'Engineer', status: 'Applied', date: '2026-05-01', notes: '' },
|
|
{ num: 302, company: 'Fabrikam', role: 'Engineer II', status: 'Applied', date: '2026-05-02', notes: 'req R-4821 mentioned' },
|
|
];
|
|
const reqIdResult = matchInvite({ company: 'Fabrikam', date: null, reqId: 'R-4821' }, reqIdRows);
|
|
eq('row with matching reqId in notes outranks identical-name row without it', reqIdResult[0].appNumber, 302);
|
|
|
|
// The req-ID boost must be case-insensitive: the invite and the tracker
|
|
// notes may case the same ID differently ("r-4821" vs "R-4821"), and a
|
|
// casing mismatch silently dropping the strongest signal is exactly the
|
|
// kind of regression this suite exists to catch.
|
|
const reqIdCaseResult = matchInvite({ company: 'Fabrikam', date: null, reqId: 'r-4821' }, reqIdRows);
|
|
eq('reqId boost still applies when invite cases the ID differently than the notes', reqIdCaseResult[0].appNumber, 302);
|
|
|
|
// Two distinct companies that each end in a *different pair* of chained
|
|
// generic descriptor words must not erode down to the same root — this is
|
|
// the actual over-stripping bug raised on PR #1497: chaining generic-word
|
|
// removal (not just legal-suffix removal) let "X Solutions Group" and
|
|
// "X Technologies Holdings" both collapse all the way to "x". Limiting
|
|
// generic-descriptor stripping to a single, non-chained pass stops at the
|
|
// first strip instead of eating through both words.
|
|
eq(
|
|
'chained generic descriptors ("Solutions Group" vs "Technologies Holdings") do not erode to the same key',
|
|
normalizeCompanyName('Northwind Solutions Group') === normalizeCompanyName('Northwind Technologies Holdings'),
|
|
false
|
|
);
|
|
|
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
if (failed > 0) {
|
|
console.log('Failures:', failures.join(', '));
|
|
process.exit(1);
|
|
}
|