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:
parent
cdd8ba20e3
commit
66f0afa58b
18 changed files with 127 additions and 53 deletions
|
|
@ -25,7 +25,7 @@ function parseArgs(argv: string[]): Record<string, string> {
|
|||
function main() {
|
||||
const argv = process.argv.slice(2)
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -34,6 +34,7 @@ function main() {
|
|||
const title = opts.title ?? ""
|
||||
const subtitle = opts.subtitle
|
||||
const out = opts.out ?? "./assets/cover.png"
|
||||
const templateDir = opts["template-dir"]
|
||||
|
||||
const slots: Record<string, string> = {}
|
||||
if (opts.slots) {
|
||||
|
|
@ -44,7 +45,7 @@ function main() {
|
|||
}
|
||||
|
||||
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 outResolved = path.resolve(out)
|
||||
|
|
|
|||
|
|
@ -70,8 +70,10 @@ export function computeHash(brand: Brand, templateSvg: string, args: RenderArgs)
|
|||
return createHash("sha256").update(data).digest("hex")
|
||||
}
|
||||
|
||||
export function loadTemplate(drawImageDir: string, name: string): string {
|
||||
const templatePath = path.join(drawImageDir, "templates", `${name}.svg`)
|
||||
export function loadTemplate(drawImageDir: string, name: string, templateDir?: string): string {
|
||||
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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
<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=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="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>
|
||||
|
Before Width: | Height: | Size: 744 B After Width: | Height: | Size: 382 B |
|
|
@ -31,7 +31,7 @@ describe("cleanup — temp SVG after render", () => {
|
|||
const leftoverPath = path.join(DRAW_IMAGE_DIR, ".tmp-render.svg")
|
||||
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(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 })
|
||||
|
||||
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(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", () => {
|
||||
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)
|
||||
|
||||
const stray = readdirSync(DRAW_IMAGE_DIR).filter((f) => f.startsWith(".tmp") && f.endsWith(".svg"))
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ function runCli(args: string[]): { status: number; stdout: string; stderr: strin
|
|||
describe("e2e — no icon render", () => {
|
||||
test("exit 0 and valid PNG without icon slot", () => {
|
||||
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(existsSync(out)).toBe(true)
|
||||
const buf = readFileSync(out)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import os from "node:os"
|
|||
import { fileURLToPath } from "node:url"
|
||||
import { spawnSync } from "node:child_process"
|
||||
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 DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
||||
|
|
@ -40,12 +41,12 @@ function assertPng(filePath: string) {
|
|||
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", () => {
|
||||
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)
|
||||
assertPng(out)
|
||||
|
||||
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)
|
||||
expect(svg).not.toContain('x="780" y="780"')
|
||||
expect(svg).not.toContain('y="930"')
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ function assertPng1024(filePath: string) {
|
|||
describe("e2e — full CLI render", () => {
|
||||
test("render cover with title and icon", () => {
|
||||
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)
|
||||
const result = JSON.parse(r.stdout)
|
||||
expect(result.status).toBe("rendered")
|
||||
|
|
@ -55,14 +55,14 @@ describe("e2e — full CLI render", () => {
|
|||
|
||||
test("render without icon succeeds", () => {
|
||||
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)
|
||||
assertPng1024(out)
|
||||
})
|
||||
|
||||
test("render with subtitle", () => {
|
||||
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)
|
||||
assertPng1024(out)
|
||||
})
|
||||
|
|
|
|||
8
.opencode/draw-image/tests/fixtures/cover.svg
vendored
Normal file
8
.opencode/draw-image/tests/fixtures/cover.svg
vendored
Normal 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 |
13
.opencode/draw-image/tests/helpers/fixtures.ts
Normal file
13
.opencode/draw-image/tests/helpers/fixtures.ts
Normal 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")
|
||||
}
|
||||
|
|
@ -1,11 +1,9 @@
|
|||
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 os from "node:os"
|
||||
import { fileURLToPath } from "node:url"
|
||||
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 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 } {
|
||||
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)
|
||||
return spawnSync("node", args, { encoding: "utf-8", cwd: DRAW_IMAGE_DIR })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import os from "node:os"
|
|||
import { fileURLToPath } from "node:url"
|
||||
import { spawnSync } from "node:child_process"
|
||||
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 DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
||||
|
|
@ -37,7 +38,7 @@ function renderToPng(svg: string, outPath: string): void {
|
|||
describe("integration — render pipeline", () => {
|
||||
test("renders valid PNG 1024x1024", () => {
|
||||
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 hash = computeHash(brand, templateSvg, args)
|
||||
const svg = buildSvg(templateSvg, brand, args, DRAW_IMAGE_DIR)
|
||||
|
|
@ -61,7 +62,7 @@ describe("integration — render pipeline", () => {
|
|||
|
||||
test("creates output directory recursively", () => {
|
||||
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 svg = buildSvg(templateSvg, brand, args, DRAW_IMAGE_DIR)
|
||||
const outPath = path.join(TMP, "a", "b", "c", "cover.png")
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import os from "node:os"
|
|||
import { fileURLToPath } from "node:url"
|
||||
import { spawnSync } from "node:child_process"
|
||||
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 DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
||||
|
|
@ -50,7 +51,7 @@ function assertPng(filePath: string) {
|
|||
describe("integration — optional subtitle and badge", () => {
|
||||
test("render without subtitle and without badge → valid PNG", () => {
|
||||
const brand = loadBrand(DRAW_IMAGE_DIR)
|
||||
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
|
||||
const templateSvg = loadFixture("cover")
|
||||
const args = { template: "cover", title: "Bare" }
|
||||
const svg = buildSvg(templateSvg, brand, args, DRAW_IMAGE_DIR)
|
||||
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", () => {
|
||||
const brand = loadBrand(DRAW_IMAGE_DIR)
|
||||
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
|
||||
const templateSvg = loadFixture("cover")
|
||||
const args = {
|
||||
template: "cover",
|
||||
title: "Full",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { describe, test, expect } from "vitest"
|
|||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
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 DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
||||
|
|
@ -13,7 +14,7 @@ const SUBTITLE_Y = 'y="930"'
|
|||
describe("unit — optional badge background", () => {
|
||||
test("empty badge slot renders no bg/border rect", () => {
|
||||
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)
|
||||
expect(svg).not.toContain(BADGE_RECT)
|
||||
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", () => {
|
||||
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: "With Badge",
|
||||
|
|
@ -36,7 +37,7 @@ describe("unit — optional badge background", () => {
|
|||
describe("unit — optional subtitle", () => {
|
||||
test("empty subtitle removes the subtitle text line", () => {
|
||||
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)
|
||||
expect(svg).not.toContain(SUBTITLE_Y)
|
||||
expect(svg).not.toContain("{{subtitle}}")
|
||||
|
|
@ -44,7 +45,7 @@ describe("unit — optional subtitle", () => {
|
|||
|
||||
test("set subtitle keeps the subtitle text line", () => {
|
||||
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: "Main",
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@ import { existsSync, rmSync, mkdirSync } from "node:fs"
|
|||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { loadBrand } from "../src/config"
|
||||
import { loadTemplate, buildSvg } from "../src/render"
|
||||
import { buildSvg } from "../src/render"
|
||||
import { parseSlots } from "../src/slot-parser"
|
||||
import { resolveSlotContent } from "../src/resolve"
|
||||
import { loadFixture } from "./helpers/fixtures"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
||||
|
|
@ -19,7 +20,7 @@ beforeAll(() => {
|
|||
describe("integration — slot resolution", () => {
|
||||
test("icon=mic resolves lucide icon and recolors to accent", () => {
|
||||
const brand = loadBrand(DRAW_IMAGE_DIR)
|
||||
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
|
||||
const templateSvg = loadFixture("cover")
|
||||
const slots = parseSlots(templateSvg)
|
||||
const iconSlot = slots.find((s) => s.name === "icon")!
|
||||
expect(iconSlot).toBeDefined()
|
||||
|
|
@ -34,7 +35,7 @@ describe("integration — slot resolution", () => {
|
|||
|
||||
test("icon=play resolves lucide icon", () => {
|
||||
const brand = loadBrand(DRAW_IMAGE_DIR)
|
||||
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
|
||||
const templateSvg = loadFixture("cover")
|
||||
const slots = parseSlots(templateSvg)
|
||||
const iconSlot = slots.find((s) => s.name === "icon")!
|
||||
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", () => {
|
||||
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
|
||||
const templateSvg = loadFixture("cover")
|
||||
const slots = parseSlots(templateSvg)
|
||||
const badgeSlot = slots.find((s) => s.name === "badge")!
|
||||
expect(badgeSlot.bg).toBe("surface")
|
||||
|
|
@ -53,7 +54,7 @@ describe("integration — slot resolution", () => {
|
|||
|
||||
test("buildSvg inserts slot content into final SVG", () => {
|
||||
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: "With Icon",
|
||||
|
|
@ -67,7 +68,7 @@ describe("integration — slot resolution", () => {
|
|||
|
||||
test("buildSvg without slots renders template defaults", () => {
|
||||
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)
|
||||
expect(svg).toContain("No Icon")
|
||||
expect(svg).not.toContain("<!-- slot:")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"hash": "646197c49cf21f46fcaa8fef8d602dce5d35a51556d969c7e3977fa59486407f",
|
||||
"generatedAt": "2026-07-31T21:56:42.585Z",
|
||||
"hash": "d052d8a9b678e54ebf2c8e7173a3b385153b71ba2cf9bb0912fbf18e41a3a8ca",
|
||||
"generatedAt": "2026-07-31T22:12:39.425Z",
|
||||
"size": 1024
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 явно просит упрощение как демонстрацию, что отвязка работает (критерий приёмки: зелёные тесты после упрощения). Упрощение убирает мёртвые слоты, которые продакшн-рендер не использовал.
|
||||
27
docs/handoff/pr-197-decouple-draw-image-tests-from-cover.md
Normal file
27
docs/handoff/pr-197-decouple-draw-image-tests-from-cover.md
Normal 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`. Удаление этих элементов из шаблона = удаление мёртвого кода для продакшн-рендера.
|
||||
|
|
@ -67,9 +67,9 @@ opencode-config/
|
|||
│ │ ├── tsconfig.json # ES2022 Bundler, strict, noEmit
|
||||
│ │ ├── vitest.config.ts # node env, tests/**/*.test.ts
|
||||
│ │ ├── 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)
|
||||
│ │ ├── 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
|
||||
│ │ ├── 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
|
||||
|
|
@ -80,23 +80,25 @@ opencode-config/
|
|||
│ │ │ ├── fit.ts # computeFit (contain/cover/stretch) + parseViewBox
|
||||
│ │ │ ├── recolor.ts # recolorSvg (stroke/fill → target) + stripSvgWrapper + extractRootAttrs
|
||||
│ │ │ ├── resolve.ts # resolveSlotContent (lucide/brand-logo/file lookup) + renderSlotSvg
|
||||
│ │ │ └── render.ts # buildSvg (assemble final SVG) + computeHash (sha256 idempotency)
|
||||
│ │ └── tests/ # vitest: 51 tests (unit + integration + e2e) — PR#178 (+3 cleanup)
|
||||
│ │ │ └── render.ts # buildSvg (assemble final SVG) + computeHash (sha256 idempotency) + loadTemplate(drawImageDir, name, templateDir?) — опц. templateDir (дефолт "templates", относит./абсолют.) — PR#197
|
||||
│ │ └── 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
|
||||
│ │ ├── slot-parser.test.ts
|
||||
│ │ ├── fit.test.ts
|
||||
│ │ ├── recolor.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)
|
||||
│ │ ├── idempotency.test.ts
|
||||
│ │ ├── slot.test.ts
|
||||
│ │ ├── e2e.test.ts
|
||||
│ │ ├── e2e.no-icon.test.ts
|
||||
│ │ ├── 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 # на fixture (PR#197)
|
||||
│ │ ├── slot.test.ts # на fixture (PR#197)
|
||||
│ │ ├── e2e.test.ts # e2e: --template-dir tests/fixtures (PR#197)
|
||||
│ │ ├── e2e.no-icon.test.ts # e2e: --template-dir tests/fixtures (PR#197)
|
||||
│ │ ├── e2e.bad-input.test.ts
|
||||
│ │ ├── render.optional.test.ts # unit: empty badge → no rect, empty subtitle → no text (PR#144)
|
||||
│ │ ├── render.optional.integration.test.ts # integration: PNG valid with/without subtitle+badge (PR#144); renderToPng → per-process mkdtempSync tmp dir + finally cleanup (PR#177)
|
||||
│ │ ├── e2e.optional.test.ts # e2e: title-only CLI → exit 0, clean SVG (PR#144); хелпер переписан — buildSvg напрямую вместо чтения leftover .tmp-render.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)
|
||||
│ │ ├── 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); на fixture (PR#197)
|
||||
│ │ ├── 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); на fixture (PR#197)
|
||||
│ ├── 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
|
||||
│ │ ├── tsconfig.json # ES2022 + DOM (для типов fetch/Blob/FormData/File), moduleResolution: Bundler, allowImportingTsExtensions: true
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue