/* Мини-тесты движка миксов. Запуск: npm run test:engine (tsx) */ import { CATEGORY_LABELS, FLAVORS, PROFILES, type FlavorCategory, type FlavorStrength, type ProfileId, } from "../src/data/flavors"; import { buildFlavorVariations, buildFocusList, encodeShareLink, generateFreshMixSet, generateMixSet, getFlavor, isColdFlavor, mulberry32, parseShareLink, randomSeed, validateMix, type GeneratedMix, type GenerateOptions, type PriorityFocus, type SharePayload, } from "../src/lib/mixEngine"; const profiles = Object.keys(PROFILES) as ProfileId[]; const strengths: FlavorStrength[] = ["light", "medium", "strong"]; const categories = Object.keys(CATEGORY_LABELS) as FlavorCategory[]; const warmFlavors = FLAVORS.filter((f) => !isColdFlavor(f)); const warmCategories = categories.filter((c) => c !== "cooling"); const failures: string[] = []; const stats = { total: 0, fallback: 0, unverified: 0, pairRepeats: 0, shortSets: 0, twoPart: 0, threePart: 0, withFrost: 0, byProfile: {} as Record, byStrength: {} as Record, }; function covers(mix: GeneratedMix, focus: PriorityFocus): boolean { return mix.items.some((i) => { const f = getFlavor(i.flavorId)!; return focus.kind === "flavor" ? f.id === focus.id : f.category === focus.category; }); } function structuralChecks(mix: GeneratedMix, seed: number) { if (mix.items.length < 2 || mix.items.length > 3) failures.push(`seed=${seed}: длина ${mix.items.length}`); const sum = mix.items.reduce((s, i) => s + i.percent, 0); if (sum !== 100) failures.push(`seed=${seed}: сумма ${sum}`); const cold = mix.items.filter((i) => isColdFlavor(getFlavor(i.flavorId)!)); if (cold.length > 1) failures.push(`seed=${seed}: несколько холодков`); cold.forEach((c) => { if (c.percent > 15) failures.push(`seed=${seed}: холодок ${c.percent}%`); }); // «Гипотеза для кальянщика» может нарушать правила качества — она помечена if (mix.unverified) return; const [base, add] = mix.items.map((i) => getFlavor(i.flavorId)!); if (base.intensity >= 4 && add.intensity >= 4) failures.push(`seed=${seed}: основа и добавка оба ≥4`); } function runSet(profile: ProfileId, opts: GenerateOptions, seed: number) { const { mixes } = generateMixSet(profile, opts, seed); const label = `seed=${seed} ${profile}/${opts.strength}/cold=${opts.cooling}`; if (mixes.length < 3) stats.shortSets += 1; // В одной выдаче составы уникальны (жёстко), пары — по возможности const signatures = new Set(); const pairs = new Set(); for (const mix of mixes) { if (signatures.has(mix.signature)) failures.push(`${label}: повтор состава ${mix.signature}`); signatures.add(mix.signature); pairs.add(mix.pairKey); } stats.pairRepeats += mixes.length - pairs.size; const focusList = buildFocusList(opts); for (const mix of mixes) { stats.total += 1; stats.byProfile[profile] = (stats.byProfile[profile] ?? 0) + 1; stats.byStrength[opts.strength] = (stats.byStrength[opts.strength] ?? 0) + 1; if (mix.items.length === 2) stats.twoPart += 1; if (mix.items.length === 3) stats.threePart += 1; if (mix.items.some((i) => isColdFlavor(getFlavor(i.flavorId)!))) stats.withFrost += 1; if (mix.curated) stats.fallback += 1; if (mix.unverified) stats.unverified += 1; // Приоритет: каждый микс содержит хотя бы один приоритетный вкус/категорию if (focusList.length > 0 && !mix.curated && !focusList.some((f) => covers(mix, f))) failures.push(`${label}: в миксе нет приоритета [${mix.items.map((i) => i.flavorId)}]`); // Исключения всегда сильнее: ни один микс не содержит стоп-лист for (const item of mix.items) { const f = getFlavor(item.flavorId)!; if (opts.excludedFlavorIds.includes(f.id)) failures.push(`${label}: ${f.id} из стоп-листа`); if (opts.excludedCategories.includes(f.category) && !mix.curated) failures.push(`${label}: категория ${f.category} из стоп-листа`); } if (mix.curated) { // кураторская заготовка не может быть гипотезой: она проверена мастером if (mix.unverified) failures.push(`${label}: заготовка помечена гипотезой`); const structural = validateMix(mix.items, profile, opts, true); if (!structural.ok) failures.push(`${label} curated: ${structural.errors.join("; ")}`); } else if (!mix.unverified) { const full = validateMix(mix.items, profile, opts); if (!full.ok) failures.push(`${label}: ${full.errors.join("; ")}`); } structuralChecks(mix, seed); } // Несколько приоритетов распределяются по карточкам if (focusList.length > 1 && !mixes.some((m) => m.curated)) { const covered = focusList.filter((f) => mixes.some((m) => covers(m, f))).length; const expected = Math.min(focusList.length, mixes.length); if (covered < expected) failures.push(`${label}: приоритеты не распределены (${covered}/${expected})`); } } const base = (strength: FlavorStrength, cooling: boolean): GenerateOptions => ({ strength, cooling, excludedCategories: [], excludedFlavorIds: [], }); /* Сценарий А: все профили × крепости × холодок, без стоп-листа. Кураторские заготовки («Проверен мастером») — легитимная часть выдачи. */ for (const profile of profiles) for (const strength of strengths) for (const cooling of [false, true]) for (let n = 0; n < 3; n++) runSet(profile, base(strength, cooling), randomSeed()); /* Сценарий Б: случайные параметры + исключённая категория */ for (let n = 0; n < 60; n++) { const seed = randomSeed(); const opts = base(strengths[(seed >>> 2) % 3], seed % 2 === 0); if (n % 4 === 0) opts.excludedCategories = [categories[(seed >>> 4) % categories.length]]; runSet(profiles[seed % profiles.length], opts, seed); } /* Сценарий В: один приоритетный вкус — для каждого вкуса, с холодком и без */ for (const flavor of warmFlavors) { const strength = (flavor.strength === "any" ? "medium" : flavor.strength) as FlavorStrength; for (const cooling of [false, true]) { runSet("fresh", { ...base(strength, cooling), priorityFlavorIds: [flavor.id] }, randomSeed()); } } /* Сценарий Г: несколько приоритетных вкусов (2–4 штуки) */ for (let n = 0; n < 40; n++) { const seed = randomSeed(); const rng = mulberry32(seed); const count = 2 + Math.floor(rng() * 3); const picked = new Set(); while (picked.size < count) picked.add(warmFlavors[Math.floor(rng() * warmFlavors.length)].id); runSet( profiles[seed % profiles.length], { ...base(strengths[(seed >>> 3) % 3], seed % 2 === 0), priorityFlavorIds: [...picked] }, seed, ); } /* Сценарий Д: приоритетные категории (+ иногда вкус) */ for (let n = 0; n < 40; n++) { const seed = randomSeed(); const rng = mulberry32(seed); const cats = new Set(); const count = 1 + Math.floor(rng() * 2); while (cats.size < count) cats.add(warmCategories[Math.floor(rng() * warmCategories.length)]); const opts: GenerateOptions = { ...base(strengths[(seed >>> 3) % 3], seed % 2 === 0), priorityCategories: [...cats], }; if (n % 3 === 0) opts.priorityFlavorIds = [warmFlavors[seed % warmFlavors.length].id]; runSet(profiles[seed % profiles.length], opts, seed); } /* Сценарий Е: вариации на каждый вкус × крепость. Составы без долей уникальны: у холодного вкуса и узких стоп-листов уникальных наборов может быть меньше 6, но не меньше 3. */ let variationCount = 0; for (const flavor of FLAVORS) { for (const strength of strengths) { const variants = buildFlavorVariations(flavor.id, strength, 123456789); variationCount += variants.length; if (variants.length < 3) failures.push(`вариаций для ${flavor.id}/${strength}: ${variants.length}, меньше 3`); if (new Set(variants.map((v) => v.signature)).size !== variants.length) failures.push(`повторы вариаций ${flavor.id}/${strength}`); for (const v of variants) { if (!v.items.some((i) => i.flavorId === flavor.id)) failures.push(`вариация ${flavor.id}/${strength} без целевого вкуса`); const structural = validateMix(v.items, v.profile, base(strength, false), true); if (!structural.ok && !v.unverified) failures.push(`вариация ${flavor.id}/${strength}: ${structural.errors.join("; ")}`); } } } /* Детерминизм и воспроизводимость «свежей» выдачи по seed */ const detOpts: GenerateOptions = { ...base("strong", true), priorityFlavorIds: ["white-grape", "honey-melon"], priorityCategories: ["citrus"], }; const deterministic = JSON.stringify(generateMixSet("fresh", detOpts, 424242)) === JSON.stringify(generateMixSet("fresh", detOpts, 424242)); if (!deterministic) failures.push("детерминизм нарушен"); const fresh = generateFreshMixSet("fresh", detOpts, []); const reproduced = JSON.stringify(generateMixSet("fresh", detOpts, fresh.seed).mixes) === JSON.stringify(fresh.mixes); if (!reproduced) failures.push("seed «свежей» выдачи не воспроизводит подборку"); /* Анти-повтор между выдачами (мягкий, считаем статистику) */ let crossRepeats = 0; let history: string[] = []; for (let n = 0; n < 20; n++) { const res = generateFreshMixSet("fresh", base("strong", false), history); crossRepeats += res.mixes.filter((m) => history.includes(m.pairKey)).length; history = res.newHistory; } /* Share: v2 туда-обратно + совместимость со старыми v1-ссылками */ const payload: SharePayload = { seed: 123456, profile: "fresh", strength: "medium", cooling: true, excludedCategories: ["spices"], excludedFlavorIds: ["cookie-uc"], priorityCategories: ["berries"], priorityFlavorIds: ["green-day", "pinkman"], }; const link = encodeShareLink(payload); const roundTrip = parseShareLink(link); if (JSON.stringify(roundTrip) !== JSON.stringify(payload)) failures.push(`share v2: ${link} → ${JSON.stringify(roundTrip)}`); const legacy = parseShareLink("#mix=v1:777:berry:strong:0:spices"); if ( !legacy || legacy.seed !== 777 || legacy.excludedCategories.join() !== "spices" || legacy.priorityFlavorIds.length !== 0 ) failures.push("share v1 больше не разбирается"); if (parseShareLink("#mix=v2:1:unknown:strong:0::::") !== null) failures.push("share: принят неизвестный профиль"); /* Статистика */ const avgLength = (stats.twoPart * 2 + stats.threePart * 3) / Math.max(1, stats.total); console.log("=== Self-test движка миксов ==="); console.log(`Всего миксов: ${stats.total}`); console.log(`Кураторских fallback: ${stats.fallback}`); console.log(`Гипотез «спроси мастера»: ${stats.unverified}`); console.log(`Повторов пар в выдаче: ${stats.pairRepeats} (составы при этом разные)`); console.log(`Неполных выдач (<3 миксов): ${stats.shortSets}`); console.log(`2-компонентных: ${stats.twoPart}, 3-компонентных: ${stats.threePart}`); console.log(`Средняя длина: ${avgLength.toFixed(2)} вкуса`); console.log(`С холодком: ${stats.withFrost}`); console.log(`Вариаций на все вкусы: ${variationCount} (уникальные составы)`); console.log(`Повторов пар между выдачами: ${crossRepeats} за 20 генераций`); console.log("По профилям:", stats.byProfile); console.log("По крепости:", stats.byStrength); console.log(`Детерминизм seed: ${deterministic ? "OK" : "FAIL"}`); console.log(`Воспроизводимость по seed: ${reproduced ? "OK" : "FAIL"}`); console.log(`Share v2 / v1: ${roundTrip && legacy ? "OK" : "FAIL"} ${link}`); if (failures.length > 0) { console.error(`\n❌ Провалено проверок: ${failures.length}`); failures.slice(0, 25).forEach((f) => console.error(" - " + f)); process.exit(1); } console.log("\n✅ Все миксы прошли валидацию");