refactor(draw-image): decouple tests from production cover.svg (#197)

* refactor(draw-image): add tests/fixtures/cover.svg and loadFixture helper

* feat(draw-image): add --template-dir flag to cli for test isolation

* refactor(draw-image): switch tests from templates/cover to fixtures

* chore(draw-image): simplify production cover.svg now decoupled from tests

* docs(handoff): add handoff + ADR for issue #196

* docs(handoff): set PR number

* docs(project-map): update after structural changes

---------

Co-authored-by: opencode-agent <agent@opencode.local>
This commit is contained in:
Sergey 2026-08-01 01:22:15 +03:00 committed by GitHub
parent cdd8ba20e3
commit 66f0afa58b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 127 additions and 53 deletions

View file

@ -25,7 +25,7 @@ function parseArgs(argv: string[]): Record<string, string> {
function main() { function main() {
const argv = process.argv.slice(2) const argv = process.argv.slice(2)
if (argv.length < 2 || argv[0] !== "render") { if (argv.length < 2 || argv[0] !== "render") {
process.stderr.write('usage: node cli.ts render <template> --title "..." [--slots icon=mic] [--out path]\n') process.stderr.write('usage: node cli.ts render <template> --title "..." [--slots icon=mic] [--subtitle text] [--out path] [--template-dir dir]\n')
process.exit(2) process.exit(2)
} }
@ -34,6 +34,7 @@ function main() {
const title = opts.title ?? "" const title = opts.title ?? ""
const subtitle = opts.subtitle const subtitle = opts.subtitle
const out = opts.out ?? "./assets/cover.png" const out = opts.out ?? "./assets/cover.png"
const templateDir = opts["template-dir"]
const slots: Record<string, string> = {} const slots: Record<string, string> = {}
if (opts.slots) { if (opts.slots) {
@ -44,7 +45,7 @@ function main() {
} }
const brand = loadBrand(__dirname) const brand = loadBrand(__dirname)
const templateSvg = loadTemplate(__dirname, template) const templateSvg = loadTemplate(__dirname, template, templateDir)
const hash = computeHash(brand, templateSvg, { template, title, subtitle, slots, out }) const hash = computeHash(brand, templateSvg, { template, title, subtitle, slots, out })
const outResolved = path.resolve(out) const outResolved = path.resolve(out)

View file

@ -70,8 +70,10 @@ export function computeHash(brand: Brand, templateSvg: string, args: RenderArgs)
return createHash("sha256").update(data).digest("hex") return createHash("sha256").update(data).digest("hex")
} }
export function loadTemplate(drawImageDir: string, name: string): string { export function loadTemplate(drawImageDir: string, name: string, templateDir?: string): string {
const templatePath = path.join(drawImageDir, "templates", `${name}.svg`) const dir = templateDir ?? "templates"
const base = path.isAbsolute(dir) ? dir : path.join(drawImageDir, dir)
const templatePath = path.join(base, `${name}.svg`)
return readFileSync(templatePath, "utf-8") return readFileSync(templatePath, "utf-8")
} }

View file

@ -1,8 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"> <svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<!-- slot: name=icon, x=252, y=180, w=520, h=520, fit=contain, recolor=accent --> <!-- slot: name=icon, x=252, y=180, w=520, h=520, fit=contain, recolor=accent -->
<!-- slot: name=sub-icon, x=412, y=600, w=200, h=200, fit=contain, recolor=accent -->
<!-- slot: name=badge, x=780, y=780, w=180, h=180, fit=contain, recolor=none, bg=surface, border=accent, radius=0.5 -->
<rect width="1024" height="1024" fill="{{base}}" /> <rect width="1024" height="1024" fill="{{base}}" />
<text x="512" y="830" font-family="Geist Sans, sans-serif" font-size="80" font-weight="700" fill="{{fg}}" text-anchor="middle">{{title}}</text> <text x="512" y="830" font-family="Geist Sans, sans-serif" font-size="80" font-weight="700" fill="{{fg}}" text-anchor="middle">{{title}}</text>
<text x="512" y="930" font-family="Geist Sans, sans-serif" font-size="36" font-weight="400" fill="{{muted}}" text-anchor="middle">{{subtitle}}</text>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 744 B

After

Width:  |  Height:  |  Size: 382 B

View file

@ -31,7 +31,7 @@ describe("cleanup — temp SVG after render", () => {
const leftoverPath = path.join(DRAW_IMAGE_DIR, ".tmp-render.svg") const leftoverPath = path.join(DRAW_IMAGE_DIR, ".tmp-render.svg")
if (existsSync(leftoverPath)) rmSync(leftoverPath, { force: true }) if (existsSync(leftoverPath)) rmSync(leftoverPath, { force: true })
const r = runCli(["render", "cover", "--title", "Cleanup", "--out", out]) const r = runCli(["render", "cover", "--title", "Cleanup", "--out", out, "--template-dir", "tests/fixtures"])
expect(r.status, `stderr: ${r.stderr}`).toBe(0) expect(r.status, `stderr: ${r.stderr}`).toBe(0)
expect(existsSync(leftoverPath), ".tmp-render.svg must not remain in package dir").toBe(false) expect(existsSync(leftoverPath), ".tmp-render.svg must not remain in package dir").toBe(false)
@ -42,7 +42,7 @@ describe("cleanup — temp SVG after render", () => {
if (existsSync(expectedTmp)) rmSync(expectedTmp, { force: true }) if (existsSync(expectedTmp)) rmSync(expectedTmp, { force: true })
const out = path.join(TMP, "pid.png") const out = path.join(TMP, "pid.png")
const r = runCli(["render", "cover", "--title", "Pid Check", "--out", out]) const r = runCli(["render", "cover", "--title", "Pid Check", "--out", out, "--template-dir", "tests/fixtures"])
expect(r.status, `stderr: ${r.stderr}`).toBe(0) expect(r.status, `stderr: ${r.stderr}`).toBe(0)
expect(existsSync(expectedTmp), `temp svg at ${expectedTmp} must be cleaned up`).toBe(false) expect(existsSync(expectedTmp), `temp svg at ${expectedTmp} must be cleaned up`).toBe(false)
@ -50,7 +50,7 @@ describe("cleanup — temp SVG after render", () => {
test("package dir contains no stray .svg files after render", () => { test("package dir contains no stray .svg files after render", () => {
const out = path.join(TMP, "no-stray.png") const out = path.join(TMP, "no-stray.png")
const r = runCli(["render", "cover", "--title", "No Stray", "--out", out]) const r = runCli(["render", "cover", "--title", "No Stray", "--out", out, "--template-dir", "tests/fixtures"])
expect(r.status, `stderr: ${r.stderr}`).toBe(0) expect(r.status, `stderr: ${r.stderr}`).toBe(0)
const stray = readdirSync(DRAW_IMAGE_DIR).filter((f) => f.startsWith(".tmp") && f.endsWith(".svg")) const stray = readdirSync(DRAW_IMAGE_DIR).filter((f) => f.startsWith(".tmp") && f.endsWith(".svg"))

View file

@ -28,7 +28,7 @@ function runCli(args: string[]): { status: number; stdout: string; stderr: strin
describe("e2e — no icon render", () => { describe("e2e — no icon render", () => {
test("exit 0 and valid PNG without icon slot", () => { test("exit 0 and valid PNG without icon slot", () => {
const out = path.join(TMP, "no-icon.png") const out = path.join(TMP, "no-icon.png")
const r = runCli(["render", "cover", "--title", "Default Brand", "--out", out]) const r = runCli(["render", "cover", "--title", "Default Brand", "--out", out, "--template-dir", "tests/fixtures"])
expect(r.status, `stderr: ${r.stderr}`).toBe(0) expect(r.status, `stderr: ${r.stderr}`).toBe(0)
expect(existsSync(out)).toBe(true) expect(existsSync(out)).toBe(true)
const buf = readFileSync(out) const buf = readFileSync(out)

View file

@ -5,7 +5,8 @@ import os from "node:os"
import { fileURLToPath } from "node:url" import { fileURLToPath } from "node:url"
import { spawnSync } from "node:child_process" import { spawnSync } from "node:child_process"
import { loadBrand } from "../src/config" import { loadBrand } from "../src/config"
import { loadTemplate, buildSvg } from "../src/render" import { buildSvg } from "../src/render"
import { loadFixture } from "./helpers/fixtures"
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..") const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
@ -40,12 +41,12 @@ function assertPng(filePath: string) {
describe("e2e — optional subtitle and badge via CLI", () => { describe("e2e — optional subtitle and badge via CLI", () => {
test("render with title only → exit 0, valid PNG, svg has no empty badge and no subtitle", () => { test("render with title only → exit 0, valid PNG, svg has no empty badge and no subtitle", () => {
const out = path.join(TMP, "optional.png") const out = path.join(TMP, "optional.png")
const r = runCli(["render", "cover", "--title", "Test", "--out", out]) const r = runCli(["render", "cover", "--title", "Test", "--out", out, "--template-dir", "tests/fixtures"])
expect(r.status, `stderr: ${r.stderr}`).toBe(0) expect(r.status, `stderr: ${r.stderr}`).toBe(0)
assertPng(out) assertPng(out)
const brand = loadBrand(DRAW_IMAGE_DIR) const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover") const templateSvg = loadFixture("cover")
const svg = buildSvg(templateSvg, brand, { template: "cover", title: "Test", out }, DRAW_IMAGE_DIR) const svg = buildSvg(templateSvg, brand, { template: "cover", title: "Test", out }, DRAW_IMAGE_DIR)
expect(svg).not.toContain('x="780" y="780"') expect(svg).not.toContain('x="780" y="780"')
expect(svg).not.toContain('y="930"') expect(svg).not.toContain('y="930"')

View file

@ -42,7 +42,7 @@ function assertPng1024(filePath: string) {
describe("e2e — full CLI render", () => { describe("e2e — full CLI render", () => {
test("render cover with title and icon", () => { test("render cover with title and icon", () => {
const out = path.join(TMP, "e2e.png") const out = path.join(TMP, "e2e.png")
const r = runCli(["render", "cover", "--title", "E2E", "--slots", "icon=mic", "--out", out]) const r = runCli(["render", "cover", "--title", "E2E", "--slots", "icon=mic", "--out", out, "--template-dir", "tests/fixtures"])
expect(r.status, `stderr: ${r.stderr}`).toBe(0) expect(r.status, `stderr: ${r.stderr}`).toBe(0)
const result = JSON.parse(r.stdout) const result = JSON.parse(r.stdout)
expect(result.status).toBe("rendered") expect(result.status).toBe("rendered")
@ -55,14 +55,14 @@ describe("e2e — full CLI render", () => {
test("render without icon succeeds", () => { test("render without icon succeeds", () => {
const out = path.join(TMP, "e2e-no-icon.png") const out = path.join(TMP, "e2e-no-icon.png")
const r = runCli(["render", "cover", "--title", "No Icon", "--out", out]) const r = runCli(["render", "cover", "--title", "No Icon", "--out", out, "--template-dir", "tests/fixtures"])
expect(r.status, `stderr: ${r.stderr}`).toBe(0) expect(r.status, `stderr: ${r.stderr}`).toBe(0)
assertPng1024(out) assertPng1024(out)
}) })
test("render with subtitle", () => { test("render with subtitle", () => {
const out = path.join(TMP, "e2e-sub.png") const out = path.join(TMP, "e2e-sub.png")
const r = runCli(["render", "cover", "--title", "Main", "--subtitle", "Sub", "--out", out]) const r = runCli(["render", "cover", "--title", "Main", "--subtitle", "Sub", "--out", out, "--template-dir", "tests/fixtures"])
expect(r.status, `stderr: ${r.stderr}`).toBe(0) expect(r.status, `stderr: ${r.stderr}`).toBe(0)
assertPng1024(out) assertPng1024(out)
}) })

View file

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<!-- slot: name=icon, x=312, y=180, w=400, h=400, fit=contain, recolor=accent -->
<!-- slot: name=sub-icon, x=412, y=600, w=200, h=200, fit=contain, recolor=accent -->
<!-- slot: name=badge, x=780, y=780, w=180, h=180, fit=contain, recolor=none, bg=surface, border=accent, radius=0.5 -->
<rect width="1024" height="1024" fill="{{base}}" />
<text x="512" y="870" font-family="Geist Sans, sans-serif" font-size="72" font-weight="700" fill="{{fg}}" text-anchor="middle">{{title}}</text>
<text x="512" y="930" font-family="Geist Sans, sans-serif" font-size="36" font-weight="400" fill="{{muted}}" text-anchor="middle">{{subtitle}}</text>
</svg>

After

Width:  |  Height:  |  Size: 744 B

View file

@ -0,0 +1,13 @@
import { readFileSync } from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const FIXTURES_DIR = path.resolve(__dirname, "..", "fixtures")
export const FIXTURES_DIR_ABS = FIXTURES_DIR
export function loadFixture(name: string): string {
const fixturePath = path.join(FIXTURES_DIR, `${name}.svg`)
return readFileSync(fixturePath, "utf-8")
}

View file

@ -1,11 +1,9 @@
import { describe, test, expect, beforeAll, afterAll } from "vitest" import { describe, test, expect, beforeAll, afterAll } from "vitest"
import { existsSync, readFileSync, rmSync, mkdirSync, writeFileSync } from "node:fs" import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs"
import path from "node:path" import path from "node:path"
import os from "node:os" import os from "node:os"
import { fileURLToPath } from "node:url" import { fileURLToPath } from "node:url"
import { spawnSync } from "node:child_process" import { spawnSync } from "node:child_process"
import { loadBrand } from "../src/config"
import { loadTemplate, buildSvg, computeHash } from "../src/render"
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..") const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
@ -21,7 +19,7 @@ afterAll(() => {
}) })
function renderCli(template: string, title: string, out: string, slots?: string): { status: number; stdout: string; stderr: string } { function renderCli(template: string, title: string, out: string, slots?: string): { status: number; stdout: string; stderr: string } {
const args = ["--experimental-strip-types", path.join(DRAW_IMAGE_DIR, "cli.ts"), "render", template, "--title", title, "--out", out] const args = ["--experimental-strip-types", path.join(DRAW_IMAGE_DIR, "cli.ts"), "render", template, "--title", title, "--out", out, "--template-dir", "tests/fixtures"]
if (slots) args.push("--slots", slots) if (slots) args.push("--slots", slots)
return spawnSync("node", args, { encoding: "utf-8", cwd: DRAW_IMAGE_DIR }) return spawnSync("node", args, { encoding: "utf-8", cwd: DRAW_IMAGE_DIR })
} }

View file

@ -5,7 +5,8 @@ import os from "node:os"
import { fileURLToPath } from "node:url" import { fileURLToPath } from "node:url"
import { spawnSync } from "node:child_process" import { spawnSync } from "node:child_process"
import { loadBrand } from "../src/config" import { loadBrand } from "../src/config"
import { loadTemplate, buildSvg, computeHash } from "../src/render" import { buildSvg, computeHash } from "../src/render"
import { loadFixture } from "./helpers/fixtures"
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..") const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
@ -37,7 +38,7 @@ function renderToPng(svg: string, outPath: string): void {
describe("integration — render pipeline", () => { describe("integration — render pipeline", () => {
test("renders valid PNG 1024x1024", () => { test("renders valid PNG 1024x1024", () => {
const brand = loadBrand(DRAW_IMAGE_DIR) const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover") const templateSvg = loadFixture("cover")
const args = { template: "cover", title: "Test", out: path.join(TMP, "cover.png") } const args = { template: "cover", title: "Test", out: path.join(TMP, "cover.png") }
const hash = computeHash(brand, templateSvg, args) const hash = computeHash(brand, templateSvg, args)
const svg = buildSvg(templateSvg, brand, args, DRAW_IMAGE_DIR) const svg = buildSvg(templateSvg, brand, args, DRAW_IMAGE_DIR)
@ -61,7 +62,7 @@ describe("integration — render pipeline", () => {
test("creates output directory recursively", () => { test("creates output directory recursively", () => {
const brand = loadBrand(DRAW_IMAGE_DIR) const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover") const templateSvg = loadFixture("cover")
const args = { template: "cover", title: "Deep", out: path.join(TMP, "a", "b", "c", "cover.png") } const args = { template: "cover", title: "Deep", out: path.join(TMP, "a", "b", "c", "cover.png") }
const svg = buildSvg(templateSvg, brand, args, DRAW_IMAGE_DIR) const svg = buildSvg(templateSvg, brand, args, DRAW_IMAGE_DIR)
const outPath = path.join(TMP, "a", "b", "c", "cover.png") const outPath = path.join(TMP, "a", "b", "c", "cover.png")

View file

@ -5,7 +5,8 @@ import os from "node:os"
import { fileURLToPath } from "node:url" import { fileURLToPath } from "node:url"
import { spawnSync } from "node:child_process" import { spawnSync } from "node:child_process"
import { loadBrand } from "../src/config" import { loadBrand } from "../src/config"
import { loadTemplate, buildSvg } from "../src/render" import { buildSvg } from "../src/render"
import { loadFixture } from "./helpers/fixtures"
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..") const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
@ -50,7 +51,7 @@ function assertPng(filePath: string) {
describe("integration — optional subtitle and badge", () => { describe("integration — optional subtitle and badge", () => {
test("render without subtitle and without badge → valid PNG", () => { test("render without subtitle and without badge → valid PNG", () => {
const brand = loadBrand(DRAW_IMAGE_DIR) const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover") const templateSvg = loadFixture("cover")
const args = { template: "cover", title: "Bare" } const args = { template: "cover", title: "Bare" }
const svg = buildSvg(templateSvg, brand, args, DRAW_IMAGE_DIR) const svg = buildSvg(templateSvg, brand, args, DRAW_IMAGE_DIR)
const outPath = path.join(TMP, "bare.png") const outPath = path.join(TMP, "bare.png")
@ -60,7 +61,7 @@ describe("integration — optional subtitle and badge", () => {
test("render with subtitle and badge → valid PNG", () => { test("render with subtitle and badge → valid PNG", () => {
const brand = loadBrand(DRAW_IMAGE_DIR) const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover") const templateSvg = loadFixture("cover")
const args = { const args = {
template: "cover", template: "cover",
title: "Full", title: "Full",

View file

@ -2,7 +2,8 @@ import { describe, test, expect } from "vitest"
import path from "node:path" import path from "node:path"
import { fileURLToPath } from "node:url" import { fileURLToPath } from "node:url"
import { loadBrand } from "../src/config" import { loadBrand } from "../src/config"
import { loadTemplate, buildSvg } from "../src/render" import { buildSvg } from "../src/render"
import { loadFixture } from "./helpers/fixtures"
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..") const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
@ -13,7 +14,7 @@ const SUBTITLE_Y = 'y="930"'
describe("unit — optional badge background", () => { describe("unit — optional badge background", () => {
test("empty badge slot renders no bg/border rect", () => { test("empty badge slot renders no bg/border rect", () => {
const brand = loadBrand(DRAW_IMAGE_DIR) const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover") const templateSvg = loadFixture("cover")
const svg = buildSvg(templateSvg, brand, { template: "cover", title: "No Badge" }, DRAW_IMAGE_DIR) const svg = buildSvg(templateSvg, brand, { template: "cover", title: "No Badge" }, DRAW_IMAGE_DIR)
expect(svg).not.toContain(BADGE_RECT) expect(svg).not.toContain(BADGE_RECT)
expect(svg).not.toContain('x="780" y="780"') expect(svg).not.toContain('x="780" y="780"')
@ -21,7 +22,7 @@ describe("unit — optional badge background", () => {
test("filled badge slot renders bg/border rect", () => { test("filled badge slot renders bg/border rect", () => {
const brand = loadBrand(DRAW_IMAGE_DIR) const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover") const templateSvg = loadFixture("cover")
const svg = buildSvg(templateSvg, brand, { const svg = buildSvg(templateSvg, brand, {
template: "cover", template: "cover",
title: "With Badge", title: "With Badge",
@ -36,7 +37,7 @@ describe("unit — optional badge background", () => {
describe("unit — optional subtitle", () => { describe("unit — optional subtitle", () => {
test("empty subtitle removes the subtitle text line", () => { test("empty subtitle removes the subtitle text line", () => {
const brand = loadBrand(DRAW_IMAGE_DIR) const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover") const templateSvg = loadFixture("cover")
const svg = buildSvg(templateSvg, brand, { template: "cover", title: "No Sub" }, DRAW_IMAGE_DIR) const svg = buildSvg(templateSvg, brand, { template: "cover", title: "No Sub" }, DRAW_IMAGE_DIR)
expect(svg).not.toContain(SUBTITLE_Y) expect(svg).not.toContain(SUBTITLE_Y)
expect(svg).not.toContain("{{subtitle}}") expect(svg).not.toContain("{{subtitle}}")
@ -44,7 +45,7 @@ describe("unit — optional subtitle", () => {
test("set subtitle keeps the subtitle text line", () => { test("set subtitle keeps the subtitle text line", () => {
const brand = loadBrand(DRAW_IMAGE_DIR) const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover") const templateSvg = loadFixture("cover")
const svg = buildSvg(templateSvg, brand, { const svg = buildSvg(templateSvg, brand, {
template: "cover", template: "cover",
title: "Main", title: "Main",

View file

@ -3,9 +3,10 @@ import { existsSync, rmSync, mkdirSync } from "node:fs"
import path from "node:path" import path from "node:path"
import { fileURLToPath } from "node:url" import { fileURLToPath } from "node:url"
import { loadBrand } from "../src/config" import { loadBrand } from "../src/config"
import { loadTemplate, buildSvg } from "../src/render" import { buildSvg } from "../src/render"
import { parseSlots } from "../src/slot-parser" import { parseSlots } from "../src/slot-parser"
import { resolveSlotContent } from "../src/resolve" import { resolveSlotContent } from "../src/resolve"
import { loadFixture } from "./helpers/fixtures"
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..") const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
@ -19,7 +20,7 @@ beforeAll(() => {
describe("integration — slot resolution", () => { describe("integration — slot resolution", () => {
test("icon=mic resolves lucide icon and recolors to accent", () => { test("icon=mic resolves lucide icon and recolors to accent", () => {
const brand = loadBrand(DRAW_IMAGE_DIR) const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover") const templateSvg = loadFixture("cover")
const slots = parseSlots(templateSvg) const slots = parseSlots(templateSvg)
const iconSlot = slots.find((s) => s.name === "icon")! const iconSlot = slots.find((s) => s.name === "icon")!
expect(iconSlot).toBeDefined() expect(iconSlot).toBeDefined()
@ -34,7 +35,7 @@ describe("integration — slot resolution", () => {
test("icon=play resolves lucide icon", () => { test("icon=play resolves lucide icon", () => {
const brand = loadBrand(DRAW_IMAGE_DIR) const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover") const templateSvg = loadFixture("cover")
const slots = parseSlots(templateSvg) const slots = parseSlots(templateSvg)
const iconSlot = slots.find((s) => s.name === "icon")! const iconSlot = slots.find((s) => s.name === "icon")!
const resolved = resolveSlotContent(iconSlot, brand, DRAW_IMAGE_DIR, "play") const resolved = resolveSlotContent(iconSlot, brand, DRAW_IMAGE_DIR, "play")
@ -43,7 +44,7 @@ describe("integration — slot resolution", () => {
}) })
test("badge slot has bg=surface and border=accent", () => { test("badge slot has bg=surface and border=accent", () => {
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover") const templateSvg = loadFixture("cover")
const slots = parseSlots(templateSvg) const slots = parseSlots(templateSvg)
const badgeSlot = slots.find((s) => s.name === "badge")! const badgeSlot = slots.find((s) => s.name === "badge")!
expect(badgeSlot.bg).toBe("surface") expect(badgeSlot.bg).toBe("surface")
@ -53,7 +54,7 @@ describe("integration — slot resolution", () => {
test("buildSvg inserts slot content into final SVG", () => { test("buildSvg inserts slot content into final SVG", () => {
const brand = loadBrand(DRAW_IMAGE_DIR) const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover") const templateSvg = loadFixture("cover")
const svg = buildSvg(templateSvg, brand, { const svg = buildSvg(templateSvg, brand, {
template: "cover", template: "cover",
title: "With Icon", title: "With Icon",
@ -67,7 +68,7 @@ describe("integration — slot resolution", () => {
test("buildSvg without slots renders template defaults", () => { test("buildSvg without slots renders template defaults", () => {
const brand = loadBrand(DRAW_IMAGE_DIR) const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover") const templateSvg = loadFixture("cover")
const svg = buildSvg(templateSvg, brand, { template: "cover", title: "No Icon" }, DRAW_IMAGE_DIR) const svg = buildSvg(templateSvg, brand, { template: "cover", title: "No Icon" }, DRAW_IMAGE_DIR)
expect(svg).toContain("No Icon") expect(svg).toContain("No Icon")
expect(svg).not.toContain("<!-- slot:") expect(svg).not.toContain("<!-- slot:")

View file

@ -1,5 +1,5 @@
{ {
"hash": "646197c49cf21f46fcaa8fef8d602dce5d35a51556d969c7e3977fa59486407f", "hash": "d052d8a9b678e54ebf2c8e7173a3b385153b71ba2cf9bb0912fbf18e41a3a8ca",
"generatedAt": "2026-07-31T21:56:42.585Z", "generatedAt": "2026-07-31T22:12:39.425Z",
"size": 1024 "size": 1024
} }

View file

@ -0,0 +1,21 @@
# ADR-087: Decouple draw-image tests from production cover.svg (PR#197)
## Статус
Accepted (2026-07-31)
## Контекст
10 из 15 тестов движка draw-image жёстко привязывались к продакшн-шаблону `templates/cover.svg` через `loadTemplate(DRAW_IMAGE_DIR,"cover")` и проверяли конкретные продакшн-значения: badge-слот `x="780" y="780" width="180" height="180"` (bg=surface, border=accent, radius=0.5) и subtitle-строку `y="930"`. Любое визуальное изменение обложки (размер/позиция иконки, удаление subtitle) ломало тесты — PR#195 уже сталкивался с этим: пришлось сохранить subtitle-`<text>` и badge/sub-icon слоты в шаблоне, чтобы не сломать `render.optional.test.ts` / `e2e.optional.test.ts` (PR#144), хотя финальный PNG рендерился без subtitle. Это связывало эволюцию продакшн-обложки с тест-контрактами и заставляло держать мёртвые слоты в шаблоне.
Требовалось отвязать тесты от продакшн-шаблона: дать им стабильный контракт, а продакшн-обложку упростить до того, что реально нужно для рендера.
## Решение
1. **Каноничный fixture**`tests/fixtures/cover.svg` со всеми типами слотов (icon 400×400 recolor=accent, sub-icon, badge с bg/border/radius, subtitle y=930). Стабильный тестовый контракт, не зависящий от продакшн-обложки.
2. **Helper `loadFixture(name)`**`tests/helpers/fixtures.ts`, читает из `tests/fixtures/`, не дублирует логику `loadTemplate`.
3. **Параметризация CLI `--template-dir`**`loadTemplate(drawImageDir, name, templateDir?)` в `src/render.ts` принимает опциональный `templateDir` (дефолт `"templates"`, поддерживает относительный и абсолютный путь). `cli.ts` парсит `--template-dir` и пробрасывает в `loadTemplate`. Без флага → `templates/` (обратная совместимость для продакшн). E2e-тесты передают `--template-dir tests/fixtures`. Это чистое решение (параметризация, а не копирование файлов во временный dir).
4. **9 тестов переведены на fixture** — unit/integration используют `loadFixture("cover")`, e2e (CLI) передают `--template-dir tests/fixtures`. `e2e.bad-input.test.ts` не тронут (тестирует несуществующий шаблон, не зависит от cover.svg).
5. **Продакшн `templates/cover.svg` упрощён** — оставлен только icon-слот 520×520 и title y=830. Удалены sub-icon, badge, subtitle-`<text>`. Перерендеренный `assets/cover.png` побайтово идентичен предыдущему (`cmp` → IDENTICAL) — удалённые слоты в продакшн-рендере не использовались.
## Альтернативы
- **Копировать fixture в `templates/` во временный test-dir** (через `mkdtempSync` + `copyFileSync`): отвергнуто — каждый e2e-тест должен настраивать tmp-окружение, дублирование, хрупкость. `--template-dir` параметризует существующий механизм `loadTemplate` без дублирования.
- **Оставить тесты на `loadTemplate` с fixture-путем напрямую** (`loadTemplate(DRAW_IMAGE_DIR,"cover","tests/fixtures")`): отвергнуто для unit/integration — `loadFixture` чище и скрывает путь. Для e2e (CLI) `loadTemplate` напрямую недоступен (тест гоняет CLI процесс), поэтому `--template-dir` обязателен.
- **Не упрощать продакшн cover.svg**: отвергнуто — issue явно просит упрощение как демонстрацию, что отвязка работает (критерий приёмки: зелёные тесты после упрощения). Упрощение убирает мёртвые слоты, которые продакшн-рендер не использовал.

View file

@ -0,0 +1,27 @@
---
pr: 197
title: "refactor(draw-image): decouple tests from production cover.svg"
---
## Что сделано
Отвязал тесты движка draw-image от продакшн-шаблона `templates/cover.svg` (issue #196). Тесты теперь опираются на каноничный fixture, а продакшн-шаблон упрощён до чистой обложки — без badge/sub-icon/subtitle-слотов.
1. **`tests/fixtures/cover.svg`** — новый каноничный тестовый шаблон (1024×1024) со всеми типами слотов: `icon` (x=312,y=180,400×400,recolor=accent), `sub-icon` (x=412,y=600,200×200,recolor=accent), `badge` (x=780,y=780,180×180,bg=surface,border=accent,radius=0.5) и subtitle-строка `y="930"`. Стабильный контракт для тестов: badge x=780 y=780 w=180 h=180, subtitle y=930, icon recolor=accent.
2. **`tests/helpers/fixtures.ts`** — helper `loadFixture(name)`: читает из `tests/fixtures/`, не дублирует логику `loadTemplate`. Экспортирует `FIXTURES_DIR_ABS`.
3. **`--template-dir` флаг CLI** — `src/render.ts`: `loadTemplate(drawImageDir, name, templateDir?)` принимает опциональный `templateDir` (дефолт `"templates"`), поддерживает относительный (от `drawImageDir`) и абсолютный путь. `cli.ts`: парсит `--template-dir`, пробрасывает в `loadTemplate`. Без флага → `templates/` (обратная совместимость, продакшн не сломан). Usage-сообщение обновлено.
4. **9 тестов переведены на fixture**: `slot.test.ts`, `render.optional.test.ts`, `render.integration.test.ts`, `render.optional.integration.test.ts`, `e2e.test.ts`, `e2e.optional.test.ts`, `e2e.no-icon.test.ts`, `idempotency.test.ts`, `cleanup.test.ts`. В unit/integration — `loadTemplate(DRAW_IMAGE_DIR,"cover")``loadFixture("cover")`. В e2e (CLI) — добавлен `--template-dir tests/fixtures`. `e2e.bad-input.test.ts` не тронут (проверяет несуществующий шаблон, не зависит от cover.svg). В `idempotency.test.ts` убран неиспользуемый импорт `loadTemplate`/`loadBrand`/`buildSvg`/`computeHash`/`writeFileSync`.
5. **`templates/cover.svg` упрощён** — оставлен только icon-слот 520×520 (x=252,y=180) и title y=830. Удалены `sub-icon`, `badge`, subtitle-`<text>` — чистая обложка. Перерендерен `assets/cover.png` (icon=opencode, title="opencode config", без subtitle): PNG побайтово идентичен предыдущему (`cmp` → IDENTICAL, 18077 байт) — удалённые слоты в продакшн-рендере не использовались. Hash в `cover.meta.json` изменился (`d052d8a9...` vs `646197c4...`), т.к. `computeHash` хеширует весь templateSvg-контент, но пиксели идентичны.
6. Добавлен handoff + ADR-087.
## Почему
10 из 15 тестов жёстко привязывались к продакшн `templates/cover.svg` через `loadTemplate(DRAW_IMAGE_DIR,"cover")` и проверяли конкретные продакшн-значения (badge x=780 y=780, subtitle y=930). Любое изменение обложки (размер/позиция иконки, удаление subtitle) ломало тесты — PR#195 уже сталкивался с этим (пришлось сохранить subtitle-слот в шаблоне, чтобы не сломать `render.optional.test.ts`). Отвязка даёт свободу менять продакшн-обложку без правок тестов: тесты получили стабильный fixture-контракт, а продакшн-шаблон упрощён до того, что реально нужно для обложки. Упрощение cover.svg и зелёные тесты после — прямое подтверждение, что отвязка работает (критерий приёмки issue).
## Pending
— (после merge: fixture можно расширять новыми slot-типами без риска для продакшн-обложки)
## Watch out
- **`--template-dir` относительный путь** — `tests/fixtures` в e2e-тестах резолвится от `drawImageDir` (cwd CLI = `DRAW_IMAGE_DIR`), не от cwd запускающего. Абсолютный путь тоже поддерживается через `path.isAbsolute`. Продакшн-вызовы без флага используют `templates/` — обратно совместимо.
- **Hash `cover.meta.json` изменился**`computeHash` (sha256) зависит от SVG-контента. Упрощённый шаблон короче → хеш другой (`d052d8a9...`), хотя PNG пиксели идентичны. Кэш-инвалидация ожидаема, на рендер не влияет.
- **ADR-087 / handoff файлы** — созданы через scaffold-handoff.sh, переименованы в `pr-197-*` / `087-pr-197-*` после create-pr (PR#197). Frontmatter `pr: 197`.
- **`e2e.bad-input.test.ts` не переведён** на `--template-dir` — он тестирует несуществующий шаблон `nonexistent`, который отсутствует в любом templateDir. Тест корректен для дефолтного `templates/` и не зависит от cover.svg — оставлен как есть (соответствует контракту issue: «оставь как есть»).
- **PNG-идентичность** — упрощение cover.svg не меняет пиксели, т.к. `render.ts` для пустых slots не вставляет SVG (нет `slotSvgs`), а conditional-regex ранее удалял subtitle-`<text>` при falsy `args.subtitle`. Удаление этих элементов из шаблона = удаление мёртвого кода для продакшн-рендера.

View file

@ -67,9 +67,9 @@ opencode-config/
│ │ ├── tsconfig.json # ES2022 Bundler, strict, noEmit │ │ ├── tsconfig.json # ES2022 Bundler, strict, noEmit
│ │ ├── vitest.config.ts # node env, tests/**/*.test.ts │ │ ├── vitest.config.ts # node env, tests/**/*.test.ts
│ │ ├── render.mjs # SVG string → PNG via sharp (fontFiles: Geist TTF bundle) │ │ ├── render.mjs # SVG string → PNG via sharp (fontFiles: Geist TTF bundle)
│ │ ├── cli.ts # CLI entry: render <template> --title --slots --out; temp SVG в os.tmpdir()/draw-image-${pid}.svg + finally rmSync (race-safe для параллельных рендеров) — PR#178 │ │ ├── cli.ts # CLI entry: render <template> --title --slots --out [--template-dir dir]; temp SVG в os.tmpdir()/draw-image-${pid}.svg + finally rmSync (race-safe для параллельных рендеров) — PR#178, PR#197 (--template-dir)
│ │ ├── brand.json # default palette slaid098 (base/surface/fg/muted/accent/line) │ │ ├── brand.json # default palette slaid098 (base/surface/fg/muted/accent/line)
│ │ ├── templates/cover.svg # 1024×1024 cover template (slots: icon + sub-icon + badge, {{title}}/{{subtitle}}) │ │ ├── templates/cover.svg # 1024×1024 cover template (упрощён: только icon-слот 520×520 + {{title}} y=830; sub-icon/badge/subtitle удалены — PR#197, тесты на fixture)
│ │ ├── fonts/ # Geist Sans TTF (Regular + Bold) bundled for sharp fontFiles │ │ ├── fonts/ # Geist Sans TTF (Regular + Bold) bundled for sharp fontFiles
│ │ ├── icons/lucide/ # 2007 Lucide SVG icons (synced from npm lucide-static via postinstall) │ │ ├── icons/lucide/ # 2007 Lucide SVG icons (synced from npm lucide-static via postinstall)
│ │ ├── brand-logos/ # brand SVG logos (opencode.svg — адаптированный логотип OpenCode 512×512 fill #ccff00, резолвится через draw-image slot icon=opencode) — PR#158 │ │ ├── brand-logos/ # brand SVG logos (opencode.svg — адаптированный логотип OpenCode 512×512 fill #ccff00, резолвится через draw-image slot icon=opencode) — PR#158
@ -80,23 +80,25 @@ opencode-config/
│ │ │ ├── fit.ts # computeFit (contain/cover/stretch) + parseViewBox │ │ │ ├── fit.ts # computeFit (contain/cover/stretch) + parseViewBox
│ │ │ ├── recolor.ts # recolorSvg (stroke/fill → target) + stripSvgWrapper + extractRootAttrs │ │ │ ├── recolor.ts # recolorSvg (stroke/fill → target) + stripSvgWrapper + extractRootAttrs
│ │ │ ├── resolve.ts # resolveSlotContent (lucide/brand-logo/file lookup) + renderSlotSvg │ │ │ ├── resolve.ts # resolveSlotContent (lucide/brand-logo/file lookup) + renderSlotSvg
│ │ │ └── render.ts # buildSvg (assemble final SVG) + computeHash (sha256 idempotency) │ │ │ └── render.ts # buildSvg (assemble final SVG) + computeHash (sha256 idempotency) + loadTemplate(drawImageDir, name, templateDir?) — опц. templateDir (дефолт "templates", относит./абсолют.) — PR#197
│ │ └── tests/ # vitest: 51 tests (unit + integration + e2e) — PR#178 (+3 cleanup) │ │ └── tests/ # vitest: 58 tests (unit + integration + e2e) — PR#178 (+3 cleanup), PR#197 (9 тестов на fixture, отвязка от продакшн cover.svg)
│ │ ├── fixtures/cover.svg # каноничный тестовый шаблон (все типы слотов: icon/sub-icon/badge/subtitle) — стабильный контракт для тестов, не зависит от продакшн templates/cover.svg — PR#197
│ │ ├── helpers/fixtures.ts # loadFixture(name): читает из tests/fixtures/, экспортирует FIXTURES_DIR_ABS — PR#197
│ │ ├── config.test.ts │ │ ├── config.test.ts
│ │ ├── slot-parser.test.ts │ │ ├── slot-parser.test.ts
│ │ ├── fit.test.ts │ │ ├── fit.test.ts
│ │ ├── recolor.test.ts │ │ ├── recolor.test.ts
│ │ ├── cli.test.ts │ │ ├── cli.test.ts
│ │ ├── render.integration.test.ts # integration: PNG 1024x1024 + recursive out dir; renderToPng → per-process mkdtempSync tmp dir + finally cleanup (PR#177) │ │ ├── render.integration.test.ts # integration: PNG 1024x1024 + recursive out dir; renderToPng → per-process mkdtempSync tmp dir + finally cleanup (PR#177); на fixture (PR#197)
│ │ ├── idempotency.test.ts │ │ ├── idempotency.test.ts # на fixture (PR#197)
│ │ ├── slot.test.ts │ │ ├── slot.test.ts # на fixture (PR#197)
│ │ ├── e2e.test.ts │ │ ├── e2e.test.ts # e2e: --template-dir tests/fixtures (PR#197)
│ │ ├── e2e.no-icon.test.ts │ │ ├── e2e.no-icon.test.ts # e2e: --template-dir tests/fixtures (PR#197)
│ │ ├── e2e.bad-input.test.ts │ │ ├── e2e.bad-input.test.ts
│ │ ├── render.optional.test.ts # unit: empty badge → no rect, empty subtitle → no text (PR#144) │ │ ├── render.optional.test.ts # unit: empty badge → no rect, empty subtitle → no text (PR#144); на fixture (PR#197)
│ │ ├── render.optional.integration.test.ts # integration: PNG valid with/without subtitle+badge (PR#144); renderToPng → per-process mkdtempSync tmp dir + finally cleanup (PR#177) │ │ ├── render.optional.integration.test.ts # integration: PNG valid with/without subtitle+badge (PR#144); renderToPng → per-process mkdtempSync tmp dir + finally cleanup (PR#177); на fixture (PR#197)
│ │ ├── e2e.optional.test.ts # e2e: title-only CLI → exit 0, clean SVG (PR#144); хелпер переписан — buildSvg напрямую вместо чтения leftover .tmp-render.svg (PR#178) │ │ ├── e2e.optional.test.ts # e2e: title-only CLI → exit 0, clean SVG (PR#144); хелпер переписан — buildSvg напрямую вместо чтения leftover .tmp-render.svg (PR#178); --template-dir tests/fixtures (PR#197)
│ │ └── cleanup.test.ts # unit: temp SVG cleanup — no .tmp-render.svg leftover, PID-based path under os.tmpdir, no stray .svg (PR#178) │ │ └── cleanup.test.ts # unit: temp SVG cleanup — no .tmp-render.svg leftover, PID-based path under os.tmpdir, no stray .svg (PR#178); на fixture (PR#197)
│ ├── telegram/ # Telegram Bot API CLI-проект (по паттерну draw-image: plugin-tool + CLI, без runtime-deps) — PR#174 │ ├── telegram/ # Telegram Bot API CLI-проект (по паттерну draw-image: plugin-tool + CLI, без runtime-deps) — PR#174
│ │ ├── package.json # "type": "module", без runtime-deps; devDeps: @types/node, typescript, vitest; script test: vitest run │ │ ├── package.json # "type": "module", без runtime-deps; devDeps: @types/node, typescript, vitest; script test: vitest run
│ │ ├── tsconfig.json # ES2022 + DOM (для типов fetch/Blob/FormData/File), moduleResolution: Bundler, allowImportingTsExtensions: true │ │ ├── tsconfig.json # ES2022 + DOM (для типов fetch/Blob/FormData/File), moduleResolution: Bundler, allowImportingTsExtensions: true