import { test } from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import sharp from "sharp"; import { openDb } from "../src/db.js"; import { createProduct } from "../src/domain/product.js"; import { setFact } from "../src/domain/facts.js"; import { registerAsset, decide, getAsset } from "../src/domain/assets.js"; import { upsertFabric, upsertShade, ensureVariantMatrix } from "../src/domain/fabric.js"; import { collectCardData, buildProductCard } from "../src/domain/productCard.js"; import type { AppConfig } from "../src/config.js"; function freshDb() { return openDb(":memory:"); } function tmpCfg(): AppConfig { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vilmal-card-")); return { mediaDir: dir } as AppConfig; } /** Wpisuje fakty potwierdzone OSKAR-a (podzbiór wystarczający do karty). */ function seedFacts(db: ReturnType, productId: string) { const rows: Array<[string, unknown]> = [ ["dimensions.width_cm", 257], ["dimensions.body_depth_cm", 104], ["dimensions.depth_cm", 151], ["dimensions.height_with_cushions_cm", 88], ["dimensions.seat_height_cm", 46], ["dimensions.seat_depth_cm", 58], ["sleeping.width_cm", 135], ["sleeping.length_cm", 190], ["cushions.loose_back_count", 5], ["mechanism.sleep", "seat_pullout_backfold"], ["features.bedding_container", true], ["sides.available", ["L", "P"]], ["sides.photographed", "P"], ["commerce.sku", "VIL-OSK-P-TIL86"], ["warranty.months", 24], ["production.country", "Polska"], ["fabric.photographed_shade", "86"], ]; for (const [key, value] of rows) { setFact(db, productId, { key, value, unit: key.endsWith("_cm") || key.endsWith("_months") ? undefined : undefined, status: "confirmed", sourceKind: "owner", sourceRef: "test", }); } // niepotwierdzone — nie wolno wejść na kartę setFact(db, productId, { key: "dimensions.chaise_width_cm", value: 130, status: "unverified", sourceKind: "owner", }); } /** Tworzy zatwierdzony asset graficzny z realnym plikiem w mediaDir. */ async function approvedImage( db: ReturnType, cfg: AppConfig, productId: string, role: string, prov: Record = {} ) { const buf = await sharp({ create: { width: 80, height: 60, channels: 3, background: { r: 200, g: 180, b: 160 } }, }) .jpeg() .toBuffer(); const sha = `sha_${role}_${Math.random().toString(36).slice(2, 8)}`; const rel = path.join("assets", "t1", `${sha}.jpg`); const abs = path.join(cfg.mediaDir, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, buf); const asset = registerAsset(db, { productId, kind: "image", role: role as "hero", sha256: sha, mime: "image/jpeg", bytes: buf.length, width: 80, height: 60, path: rel, origin: "ai_generated", provenance: prov, }); decide(db, { productId, scope: "shot", target: role, assetId: asset.id, decision: "approved", decidedBy: "test", }); return asset; } test("collectCardData: braki zgłaszane zamiast pustej karty", () => { const db = freshDb(); const cfg = tmpCfg(); const p = createProduct(db, { slug: "x", name: "X", kind: "corner-sofa" }); seedFacts(db, p.id); const data = collectCardData(db, p.id, cfg.mediaDir); assert.ok(data.missing.some((m) => m.includes("blueprint"))); assert.ok(data.missing.some((m) => m.includes("zdjęcie główne"))); }); test("collectCardData: parametry tylko z confirmed, unverified wykluczone", async () => { const db = freshDb(); const cfg = tmpCfg(); const p = createProduct(db, { slug: "x", name: "X", kind: "corner-sofa" }); seedFacts(db, p.id); await approvedImage(db, cfg, p.id, "hero"); await approvedImage(db, cfg, p.id, "blueprint", { mode: "ai" }); const data = collectCardData(db, p.id, cfg.mediaDir); assert.equal(data.missing.length, 0); assert.equal(data.blueprintAiDrawn, true); const labels = data.params.map(([k]) => k); assert.ok(labels.includes("Wymiary gabarytowe")); assert.ok(data.params.some(([label, value]) => label === "Głębokość korpusu" && value === "104 cm")); assert.ok(!labels.includes("Wysokość korpusu")); assert.ok(labels.includes("Powierzchnia spania")); assert.ok(labels.includes("Mechanizm rozkładania")); // unverified chaise_width nie wchodzi assert.ok(!data.params.some(([, v]) => v.includes("130"))); assert.ok(!data.factsSnapshot["dimensions.chaise_width_cm"]); // cena nie wchodzi na kartę nawet gdy confirmed — sekcja parametrów jej nie zawiera assert.ok(!labels.some((l) => l.toLowerCase().includes("cena"))); // EAN pokazany jako jawny stan assert.ok(data.identifiers.some(([k, v]) => k === "EAN" && v === "do nadania")); }); test("buildProductCard: PDF na dysku + asset document z provenance", async () => { const db = freshDb(); const cfg = tmpCfg(); const p = createProduct(db, { slug: "x", name: "X", kind: "corner-sofa" }); seedFacts(db, p.id); const hero = await approvedImage(db, cfg, p.id, "hero"); const bp = await approvedImage(db, cfg, p.id, "blueprint", { mode: "ai" }); await approvedImage(db, cfg, p.id, "packshot_front"); const out = await buildProductCard(db, cfg, p.id); assert.ok("asset" in out, JSON.stringify(out)); if (!("asset" in out)) return; const asset = out.asset; assert.equal(asset.kind, "pdf"); assert.equal(asset.role, "document"); assert.equal(asset.mime, "application/pdf"); const abs = path.join(cfg.mediaDir, asset.path); const buf = fs.readFileSync(abs); assert.ok(buf.subarray(0, 5).toString() === "%PDF-"); assert.ok(buf.length > 20_000); // osadzone fonty + obrazy const prov = JSON.parse(getAsset(db, asset.id)!.provenance_json) as { factsSnapshot: Record; images: Array<{ role: string; sha256: string }>; blueprint: { assetId: string; sha256: string }; }; assert.equal(prov.blueprint.assetId, bp.id); assert.equal(prov.blueprint.sha256, bp.sha256); assert.ok(prov.images.some((i) => i.assetId === hero.id || i.sha256 === hero.sha256)); assert.equal(prov.factsSnapshot["dimensions.width_cm"], 257); assert.equal(prov.factsSnapshot["commerce.sku"], "VIL-OSK-P-TIL86"); // druga generacja → nowa wersja dokumentu const out2 = await buildProductCard(db, cfg, p.id); assert.ok("asset" in out2); if ("asset" in out2) assert.equal(out2.asset.version, asset.version + 1); }); test("buildProductCard: brak blueprintu → missing, zero assetu", async () => { const db = freshDb(); const cfg = tmpCfg(); const p = createProduct(db, { slug: "x", name: "X", kind: "corner-sofa" }); seedFacts(db, p.id); await approvedImage(db, cfg, p.id, "hero"); const out = await buildProductCard(db, cfg, p.id); assert.ok("missing" in out); assert.equal( (db.prepare("SELECT COUNT(*) AS c FROM assets WHERE role = 'document'").get() as { c: number }).c, 0 ); }); test("collectCardData: tkanina tylko publikowalne parametry (bez flammability)", async () => { const db = freshDb(); const cfg = tmpCfg(); const p = createProduct(db, { slug: "x", name: "X", kind: "corner-sofa" }); seedFacts(db, p.id); const tilia = upsertFabric(db, { code: "tilia", name: "TILIA", producer: "Davis", params: { composition: "100% poliester", grammage_g_m2: { value: 420, tolerance: "±5%" }, martindale_cycles: { min: 90000, max: 100000, category: "A" }, flammability_passed: ["EN 1021-1"], // NIE publikowalne easy_clean: false, }, verification: "confirmed", }); upsertShade(db, tilia.id, { code: "86", verification: "confirmed" }); ensureVariantMatrix(db, { productId: p.id, fabricId: tilia.id, sides: ["L", "P"] }); await approvedImage(db, cfg, p.id, "hero"); await approvedImage(db, cfg, p.id, "blueprint"); const data = collectCardData(db, p.id, cfg.mediaDir); const joined = data.fabricLines.join("\n"); assert.ok(joined.includes("100% poliester")); assert.ok(joined.includes("420 g/m²")); assert.ok(joined.includes("Martindale")); assert.ok(joined.includes("TILIA 86")); assert.ok(!joined.includes("1021")); // trudnopalność nie wchodzi assert.ok(!joined.toLowerCase().includes("easy clean")); });