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 crypto from "node:crypto"; import sharp from "sharp"; import { openDb } from "../src/db.js"; import { loadConfig, type AppConfig } from "../src/config.js"; import { registerAsset } from "../src/domain/assets.js"; import { reviewAsset } from "../src/domain/macius.js"; process.env.VILMAL_MACIUS_PROVIDER = "rules"; function tmpCfg(): AppConfig { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vilmal-macius-")); const cfg = loadConfig({ dataDir: dir, dbPath: path.join(dir, "t.db"), env: "test" }); cfg.mediaDir = path.join(dir, "media"); cfg.originalsDir = path.join(cfg.mediaDir, "originals"); cfg.derivedDir = path.join(cfg.mediaDir, "derived"); fs.mkdirSync(cfg.originalsDir, { recursive: true }); fs.mkdirSync(cfg.derivedDir, { recursive: true }); return cfg; } /** Obraz 120×80: lewa połowa "mebel" z mocnym gradientem (faktura), prawa "tło". mirror odwraca w poziomie. */ async function synthImage( fg: [number, number, number], bg: [number, number, number], mirror = false ): Promise { const w = 120, h = 80; const px = Buffer.alloc(w * h * 3); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const o = (y * w + x) * 3; const inFg = mirror ? x >= w / 2 : x < w / 2; // silny gradient ±30 — płaski region daje std≈0 i texRatio byłoby // niestabilne; słaby gradient JPEG złapie przez subsamplingu chromy const t = inFg ? (x % 8) * 8 - 30 : 0; const c = inFg ? fg : bg; px[o] = Math.max(0, Math.min(255, c[0] + t)); px[o + 1] = Math.max(0, Math.min(255, c[1] + t)); px[o + 2] = Math.max(0, Math.min(255, c[2] + t)); } } return sharp(px, { raw: { width: w, height: h, channels: 3 } }).jpeg({ quality: 95 }).toBuffer(); } async function putAsset( cfg: AppConfig, db: ReturnType, img: Buffer, parentId: string | null, provenance: Record ) { const sha = crypto.createHash("sha256").update(img).digest("hex"); const rel = path.join("assets", sha.slice(0, 2), `${sha}.jpg`); const abs = path.join(cfg.mediaDir, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, img); return registerAsset(db, { productId: "prd_test", kind: "image", role: "packshot_34", parentAssetId: parentId, sha256: sha, mime: "image/jpeg", bytes: img.length, width: 120, height: 80, path: rel.replaceAll("\\", "/"), origin: "derived", provenance, }); } async function setup() { const cfg = tmpCfg(); const db = openDb(":memory:"); db.prepare( "INSERT INTO products (id, slug, name, kind, status, created_at, updated_at) VALUES ('prd_test','t','T','x','active','now','now')" ).run(); return { cfg, db }; } test("macius-rules: recolor zgodny z maską → ok", async () => { const { cfg, db } = await setup(); const master = await putAsset(cfg, db, await synthImage([160, 140, 120], [40, 40, 42]), null, {}); // wariant: lewa połowa przefarbowana, tło nietknięte const variant = await putAsset(cfg, db, await synthImage([40, 52, 80], [40, 40, 42]), master.id, { mask: { coverage: 0.5 }, mirrored: false, }); const v = await reviewAsset(db, cfg, { assetId: variant.id, createdBy: "test" }); assert.equal(v.verdict, "ok"); assert.equal(v.model, "macius-rules"); const raw = JSON.parse(JSON.parse(v.raw_json).response) as { rules: Record }; assert.ok(Math.abs(raw.rules.fracChanged! - 0.5) < 0.15); }); test("macius-rules: wyciek koloru poza maskę → odrzucone", async () => { const { cfg, db } = await setup(); const master = await putAsset(cfg, db, await synthImage([160, 140, 120], [40, 40, 42]), null, {}); // cały kadr przefarbowany przy masce 50% → zasięg 2× maska const variant = await putAsset(cfg, db, await synthImage([40, 52, 80], [30, 40, 60]), master.id, { mask: { coverage: 0.5 }, mirrored: false, }); const v = await reviewAsset(db, cfg, { assetId: variant.id, createdBy: "test" }); assert.equal(v.verdict, "odrzucone"); assert.match(v.issues_json, /rozlewa|wyciek/); }); test("macius-rules: wariant lustrzany porównuje się do odwróconego mastera", async () => { const { cfg, db } = await setup(); const master = await putAsset(cfg, db, await synthImage([160, 140, 120], [40, 40, 42]), null, {}); // wariant L = lustro mastera + przefarbowanie mebla (po prawej stronie) const variant = await putAsset(cfg, db, await synthImage([40, 52, 80], [40, 40, 42], true), master.id, { mask: { coverage: 0.5 }, mirrored: true, }); const v = await reviewAsset(db, cfg, { assetId: variant.id, createdBy: "test" }); assert.equal(v.verdict, "ok"); }); test("macius-rules: makro fullFrame używa całego kadru i normalizuje kanały RGB", async () => { const { cfg, db } = await setup(); const rgb = await sharp({ create: { width: 120, height: 80, channels: 3, background: { r: 50, g: 60, b: 70 } } }).png().toBuffer(); const alpha = await sharp(rgb).ensureAlpha(0.6).png().toBuffer(); const master = await putAsset(cfg, db, alpha, null, {}); const maskDir = path.join(cfg.derivedDir, "masks"); fs.mkdirSync(maskDir, { recursive: true }); fs.writeFileSync(path.join(maskDir, `${master.sha256}-${process.env.VILMAL_MASK_SESSION ?? "birefnet-general"}.png`), alpha); fs.writeFileSync(path.join(cfg.originalsDir, "swatch.png"), rgb); db.prepare("INSERT INTO source_assets(id,role,sha256,mime,bytes,original_name,path,created_at) VALUES(?,?,?,?,?,?,?,?)").run("test-swatch", "fabric_reference", "test-sha", "image/png", rgb.length, "swatch.png", "swatch.png", "now"); const variant = await putAsset(cfg, db, rgb, master.id, { mask: { fullFrame: true, coverage: 1 }, swatches: { target: "test-swatch" } }); const v = await reviewAsset(db, cfg, { assetId: variant.id, provider: "rules", createdBy: "test" }); const metrics = JSON.parse(JSON.parse(v.raw_json).response).rules; assert.equal(typeof metrics.deltaE, "number"); assert.ok(metrics.deltaE < 0.1); assert.equal(metrics.fracChanged, 0); db.close(); fs.rmSync(cfg.dataDir, { recursive: true, force: true }); }); test("macius-rules: asset bez mastera → neutralne ok (ocena ograniczona)", async () => { const { cfg, db } = await setup(); const lone = await putAsset(cfg, db, await synthImage([160, 140, 120], [40, 40, 42]), null, {}); const v = await reviewAsset(db, cfg, { assetId: lone.id, createdBy: "test" }); assert.equal(v.verdict, "ok"); assert.match(v.justification, /brak mastera/i); });