← back to Whatsmystyle
scripts/test-tailor.js
119 lines
/**
* Tailor-the-view scoring tests (tick 16).
*
* The scoring heuristics live in public/js/app.js (browser-side). This file
* reimplements them as a pure CJS module so we can sanity-check the ranking
* without spinning up a headless browser. If the live UI is wrong, this test
* is no better than guessing — but if the test is wrong, the live UI is too,
* which is what we want to catch.
*
* Run:
* node scripts/test-tailor.js
*/
const ERA_WORDS = [
[/\b(victorian|edwardian)\b/i, 1900],
[/\b(1920s|art deco|gatsby|jazz age)\b/i, 1925],
[/\b(1930s|depression|hollywood golden)\b/i, 1935],
[/\b(1940s|war[- ]era|forties)\b/i, 1945],
[/\b(1950s|fifties|midcentury|mid[- ]?century|atomic)\b/i, 1955],
[/\b(1960s|sixties|mod|psychedelic|space age)\b/i, 1965],
[/\b(1970s|seventies|disco|bohemian|hippie)\b/i, 1975],
[/\b(1980s|eighties|new wave|preppy revival|power)\b/i, 1985],
[/\b(1990s|nineties|grunge|minimal[- ]?ist)\b/i, 1995],
[/\b(y2k|early 2000s|2000s)\b/i, 2005],
[/\b(2010s)\b/i, 2015],
[/\b(modern|contemporary|current|today)\b/i, 2025],
[/\bvintage\b/i, 1965],
[/\bretro\b/i, 1975],
];
function eraOf(it) {
const text = `${it.title || ''} ${it.brand || ''} ${it.tags || ''}`;
for (const [re, year] of ERA_WORDS) if (re.test(text)) return year;
return null;
}
const PALETTE_SATURATED = /\b(red|crimson|cobalt|emerald|fuchsia|magenta|electric|neon|vibrant|saffron|chartreuse|tangerine|hot pink|royal blue)\b/i;
const PALETTE_NEUTRAL = /\b(beige|stone|ivory|cream|oat|sand|taupe|nude|mushroom|greige|bone|natural)\b/i;
const TONE_FORMAL = /\b(blazer|tuxedo|gown|silk|wool|tweed|cashmere|tailored|suit|oxford|loafer|trouser|evening|cocktail)\b/i;
const TONE_CASUAL = /\b(tee|t-shirt|hoodie|sweatpant|jean|denim|sneaker|track|jogger|cargo|baseball)\b/i;
function tailorScore(it, tailor) {
let s = 0;
const yr = eraOf(it);
if (yr !== null) {
const delta = Math.abs(yr - tailor.era);
s += Math.max(0, 1 - delta / 25) * 1.5;
}
const text = `${it.title || ''} ${it.brand || ''} ${it.color || ''} ${it.tags || ''}`;
const sat = PALETTE_SATURATED.test(text) ? 1 : 0;
const neu = PALETTE_NEUTRAL.test(text) ? 1 : 0;
const palT = tailor.palette / 100;
s += sat * palT * 0.8 + neu * (1 - palT) * 0.8;
const fml = TONE_FORMAL.test(text) ? 1 : 0;
const csl = TONE_CASUAL.test(text) ? 1 : 0;
const tonT = tailor.tone / 100;
s += fml * tonT * 0.7 + csl * (1 - tonT) * 0.7;
return s;
}
const FIXTURES = [
{ id: 1, title: 'Disco Wrap Dress', brand: 'Halston', color: 'gold', tags: '1970s, bohemian' },
{ id: 2, title: 'Midcentury Sheath', brand: 'Dior', color: 'ivory', tags: 'fifties, atomic' },
{ id: 3, title: 'Y2K Cargo Pants', brand: 'Diesel', color: 'denim', tags: 'y2k, low rise' },
{ id: 4, title: 'Modern Wool Blazer', brand: 'Theory', color: 'charcoal', tags: 'contemporary, tailored' },
{ id: 5, title: 'Vibrant Cobalt Trouser', brand: 'Marni', color: 'cobalt', tags: 'modern' },
{ id: 6, title: 'Stone Linen Tee', brand: 'Everlane', color: 'stone', tags: 'casual basics' },
{ id: 7, title: 'Cashmere Evening Gown', brand: 'The Row', color: 'black', tags: 'formal' },
{ id: 8, title: 'Vintage Hoodie', brand: 'Champion', color: 'grey', tags: 'retro casual' },
];
let pass = 0, fail = 0;
const failures = [];
function expect(name, cond, detail) {
if (cond) { pass++; }
else { fail++; failures.push(`[${name}] ${detail}`); }
}
// Test 1 — era detection
expect('era:halston', eraOf(FIXTURES[0]) === 1975, `got ${eraOf(FIXTURES[0])}, want 1975`);
expect('era:dior', eraOf(FIXTURES[1]) === 1955, `got ${eraOf(FIXTURES[1])}, want 1955`);
expect('era:y2k', eraOf(FIXTURES[2]) === 2005, `got ${eraOf(FIXTURES[2])}, want 2005`);
expect('era:theory', eraOf(FIXTURES[3]) === 2025, `got ${eraOf(FIXTURES[3])}, want 2025`);
expect('era:everlane', eraOf(FIXTURES[5]) === null, `unknown era should be null, got ${eraOf(FIXTURES[5])}`);
// Test 2 — Steve's default tailor (Era 1975, Palette 93, Density 277, Tone 73)
// Expectations:
// - Disco Wrap Dress (Halston, 1975, gold/bohemian) ranks #1 — exact era hit
// - Vibrant Cobalt Trouser (modern, saturated, formal) ranks high — saturated + formal hits
// - Stone Linen Tee (neutral, casual) ranks LOW — neutral conflicts with palette=93, casual conflicts with tone=73
const steveDefaults = { era: 1975, palette: 93, density: 277, tone: 73 };
const scored = FIXTURES.map(f => ({ ...f, score: tailorScore(f, steveDefaults) }));
scored.sort((a, b) => b.score - a.score);
expect('rank:halston-first', scored[0].id === 1, `expected Halston Disco first, got ${scored[0].title}`);
const tee = scored.find(s => s.id === 6);
const cobalt = scored.find(s => s.id === 5);
expect('rank:cobalt-beats-tee', cobalt.score > tee.score, `cobalt score ${cobalt.score.toFixed(3)} should beat tee ${tee.score.toFixed(3)}`);
// Test 3 — palette inversion. Drop palette to 5 and stone-tee should now beat cobalt.
const lowPalette = { era: 1975, palette: 5, density: 277, tone: 73 };
const teeLow = tailorScore(FIXTURES[5], lowPalette);
const cobaltLow = tailorScore(FIXTURES[4], lowPalette);
expect('palette-inversion', teeLow > cobaltLow, `at palette=5, tee ${teeLow.toFixed(3)} should beat cobalt ${cobaltLow.toFixed(3)}`);
// Test 4 — tone inversion. Drop tone to 5 and hoodie should beat evening gown.
const lowTone = { era: 2000, palette: 50, density: 277, tone: 5 };
const hoodie = tailorScore(FIXTURES[7], lowTone);
const gown = tailorScore(FIXTURES[6], lowTone);
expect('tone-inversion', hoodie > gown, `at tone=5, hoodie ${hoodie.toFixed(3)} should beat gown ${gown.toFixed(3)}`);
// Test 5 — era window. Item with 1975 hit should score zero at era=2025 (Δ=50 > 25 window).
const farEra = tailorScore(FIXTURES[0], { era: 2025, palette: 0, density: 277, tone: 0 });
expect('era-window-cutoff', farEra < 0.01, `Halston at era=2025 should score ~0, got ${farEra.toFixed(3)}`);
console.log(JSON.stringify({
ok: fail === 0,
pass, fail,
failures: failures.slice(0, 20),
top3_at_steve_defaults: scored.slice(0, 3).map(s => ({ title: s.title, score: Number(s.score.toFixed(3)) })),
}, null, 2));
process.exit(fail === 0 ? 0 : 1);