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