initial site
This commit is contained in:
commit
da5b259d06
167 files changed
+34157
No files matched your search
@@ -0,0 +1,293 @@
|
||||
/* Мини-тесты движка миксов. Запуск: 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<string, number>,
|
||||
byStrength: {} as Record<string, number>,
|
||||
};
|
||||
|
||||
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<string>();
|
||||
const pairs = new Set<string>();
|
||||
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<string>();
|
||||
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<FlavorCategory>();
|
||||
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✅ Все миксы прошли валидацию");
|
||||
Reference in new issue
Block a user