diff --git a/docs/phase1_knowledge.md b/docs/phase1_knowledge.md index 4b1c95f..f4d55a3 100644 --- a/docs/phase1_knowledge.md +++ b/docs/phase1_knowledge.md @@ -8,6 +8,7 @@ Implement executable, testable milestones of P17 without drifting from locked re - Deterministic recommendation engine (`recommend`) with mode/surface/orientation/workflow inputs. - Deterministic analyze engine (`analyze`) with file input and tier classification output. +- Deterministic report engine (`report`) layered on analyze output with checklist + next-actions contract. - Analyze media inspector expanded from `PPM` to `PNG`, `JPEG`, `MP4`, and `MOV`. - Video probing baseline added with `ffprobe` (`width`, `height`, `aspect`, `orientation`, `codec`, `fps`). - Analyze metadata baseline extended for video to include `duration_seconds` and `bitrate_kbps`. @@ -21,6 +22,7 @@ Implement executable, testable milestones of P17 without drifting from locked re - White-canvas policy logic for feed profiles with workflow-gated `feed_app_direct` fallback. - Locked margin formula v1 and contain/no-crop invariant flags. - CLI command and JSON/human outputs. +- Added `report` CLI + domain slice with deterministic checklist checks and stable JSON snapshots. - Layered test suite expansion: - unit tests - integration tests (CLI behavior and contract checks) @@ -44,6 +46,7 @@ Implement executable, testable milestones of P17 without drifting from locked re - Added export-video integration/e2e snapshot tests and dedicated export-video snapshot fixtures. - Added failing-first export-video assertions to verify returned output metadata against the exported file. - Added failing-first export-video assertions proving audio is stripped from audio-input videos in baseline export mode. +- Added report integration/e2e snapshot tests and deterministic report fixtures. ## Deferred diff --git a/package.json b/package.json index e84e8a6..5a53e98 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "recommend": "bun run src/cli/recommend.ts", "analyze": "bun run src/cli/analyze.ts", + "report": "bun run src/cli/report.ts", "export-image": "bun run src/cli/export_image.ts", "export-video": "bun run src/cli/export_video.ts", "test": "bun test", @@ -20,6 +21,7 @@ "fixtures:images:raster": "bun run tests/fixtures/images/generate-raster-fixtures.ts", "fixtures:e2e": "bun run tests/fixtures/e2e/generate-snapshots.ts", "fixtures:e2e:analyze": "bun run tests/fixtures/e2e/generate-analyze-snapshots.ts", + "fixtures:e2e:report": "bun run tests/fixtures/e2e/generate-report-snapshots.ts", "fixtures:e2e:export": "bun run tests/fixtures/e2e/generate-export-snapshots.ts", "fixtures:e2e:export-video": "bun run tests/fixtures/e2e/generate-export-video-snapshots.ts", "fixtures:visual": "bun run tests/fixtures/visual/generate-snapshots.ts", diff --git a/src/cli/report.ts b/src/cli/report.ts new file mode 100644 index 0000000..fd434d0 --- /dev/null +++ b/src/cli/report.ts @@ -0,0 +1,128 @@ +import { + CANVAS_PROFILES, + MODES, + SURFACES, + WORKFLOWS, + type CanvasProfile, + type Mode, + type ReportInput, + type Surface, + type Workflow, +} from "../types/contracts"; +import { stableStringify } from "../domain/recommend"; +import { buildReport } from "../domain/report"; + +type ParsedArgs = ReportInput & { json: boolean }; + +function isAllowedValue(value: string, allowed: readonly T[]): value is T { + return (allowed as readonly string[]).includes(value); +} + +function parseArgs(argv: string[]): ParsedArgs { + if (argv.length === 0 || argv[0]?.startsWith("--")) { + throw new Error("Missing required positional arg: "); + } + + const file = argv[0]!; + let mode: Mode | undefined; + let surface: Surface | undefined; + let workflow: Workflow = "unknown"; + let whiteCanvas = false; + let canvasProfile: CanvasProfile | undefined; + let json = false; + + for (let i = 1; i < argv.length; i += 1) { + const token = argv[i]; + const next = argv[i + 1]; + + switch (token) { + case "--mode": + if (!next || next.startsWith("--")) { + throw new Error("Missing value for --mode"); + } + mode = next as Mode; + i += 1; + break; + case "--surface": + if (!next || next.startsWith("--")) { + throw new Error("Missing value for --surface"); + } + surface = next as Surface; + i += 1; + break; + case "--workflow": + if (!next || next.startsWith("--")) { + throw new Error("Missing value for --workflow"); + } + workflow = next as Workflow; + i += 1; + break; + case "--white-canvas": + whiteCanvas = true; + break; + case "--canvas-profile": + if (!next || next.startsWith("--")) { + throw new Error("Missing value for --canvas-profile"); + } + canvasProfile = next as CanvasProfile; + i += 1; + break; + case "--json": + json = true; + break; + default: + throw new Error(`Unknown argument: ${token}`); + } + } + + if (!mode || !surface) { + throw new Error("Missing required args: --mode --surface"); + } + + if (!isAllowedValue(mode, MODES)) { + throw new Error(`Invalid mode: ${mode}`); + } + + if (!isAllowedValue(surface, SURFACES)) { + throw new Error(`Invalid surface: ${surface}`); + } + + if (!isAllowedValue(workflow, WORKFLOWS)) { + throw new Error(`Invalid workflow: ${workflow}`); + } + + if (canvasProfile && !isAllowedValue(canvasProfile, CANVAS_PROFILES)) { + throw new Error(`Invalid canvas profile: ${canvasProfile}`); + } + + return { file, mode, surface, workflow, whiteCanvas, canvasProfile, json }; +} + +function printHumanOutput(result: ReturnType): void { + const analyzed = result.analyze; + console.log(`Summary: ${analyzed.input.path} -> ${analyzed.selection.target_resolution}`); + console.log(`Tier: ${analyzed.tier.name} (${analyzed.tier.risk_level})`); + console.log("Checks:"); + for (const check of result.checks) { + console.log(`- [${check.status}] ${check.label}: ${check.message}`); + } + console.log("Next actions:"); + for (const action of result.next_actions) { + console.log(`- ${action}`); + } + console.log("Next action: rerun with --json for machine-readable output."); +} + +function main(): void { + const parsed = parseArgs(process.argv.slice(2)); + const result = buildReport(parsed); + + if (parsed.json) { + console.log(stableStringify(result)); + return; + } + + printHumanOutput(result); +} + +main(); diff --git a/src/domain/report.ts b/src/domain/report.ts new file mode 100644 index 0000000..0a39bfa --- /dev/null +++ b/src/domain/report.ts @@ -0,0 +1,91 @@ +import type { ReportInput, ReportOutput } from "../types/contracts"; +import { analyze } from "./analyze"; + +export function buildReport(input: ReportInput): ReportOutput { + const analyzed = analyze(input); + + const checks = [ + analyzed.input.width >= 320 + ? { + id: "input_width_min", + label: "Input width baseline", + status: "pass" as const, + message: `Input width ${analyzed.input.width}px is within baseline threshold.`, + } + : { + id: "input_width_min", + label: "Input width baseline", + status: "warn" as const, + message: `Input width ${analyzed.input.width}px is below baseline threshold (320px).`, + }, + analyzed.tier.name === "tier_aspect_correction" + ? { + id: "aspect_fit", + label: "Aspect fit", + status: "warn" as const, + message: "Input aspect is outside supported bounds for selected surface.", + } + : { + id: "aspect_fit", + label: "Aspect fit", + status: "pass" as const, + message: "Input aspect is within supported bounds for selected surface.", + }, + analyzed.input.codec === null + ? { + id: "audio_present", + label: "Audio track", + status: "pass" as const, + message: "Still image input: audio track is not applicable.", + } + : analyzed.input.has_audio + ? { + id: "audio_present", + label: "Audio track", + status: "pass" as const, + message: "Audio track detected in input video.", + } + : { + id: "audio_present", + label: "Audio track", + status: "warn" as const, + message: "No audio track detected in input video.", + }, + analyzed.input.codec === null || analyzed.input.codec === "h264" + ? { + id: "codec_preference", + label: "Codec preference", + status: "pass" as const, + message: + analyzed.input.codec === null + ? "Still image input: codec preference is not applicable." + : "Input codec is h264 and matches baseline preference.", + } + : { + id: "codec_preference", + label: "Codec preference", + status: "warn" as const, + message: `Input codec ${analyzed.input.codec} differs from h264 baseline preference.`, + }, + ]; + + const nextActions = [ + analyzed.input.codec === null + ? "Use export-image for deterministic still export." + : "Use export-video for deterministic video export.", + ]; + + if (checks.some((check) => check.status === "warn")) { + nextActions.push("Review warning checks before upload."); + } + + if (analyzed.white_canvas.enabled) { + nextActions.push("Confirm white-canvas margins visually before posting."); + } + + return { + analyze: analyzed, + checks, + next_actions: nextActions, + }; +} diff --git a/src/types/contracts.ts b/src/types/contracts.ts index 4b6de99..be20bfb 100644 --- a/src/types/contracts.ts +++ b/src/types/contracts.ts @@ -171,3 +171,20 @@ export type ExportVideoOutput = { output_has_audio: boolean; output_audio_codec: string | null; }; + +export type ReportInput = AnalyzeInput; + +export type ReportCheckStatus = "pass" | "warn"; + +export type ReportCheck = { + id: string; + label: string; + status: ReportCheckStatus; + message: string; +}; + +export type ReportOutput = { + analyze: AnalyzeOutput; + checks: ReportCheck[]; + next_actions: string[]; +}; diff --git a/tests/e2e/report.snapshots.e2e.test.ts b/tests/e2e/report.snapshots.e2e.test.ts new file mode 100644 index 0000000..9d69fe4 --- /dev/null +++ b/tests/e2e/report.snapshots.e2e.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { isAbsolute, join, relative } from "node:path"; +import { parseJsonStdout, runReportCli } from "../helpers/cli"; + +type ReportCase = { + id: string; + args: string[]; +}; + +const fixtureDir = join(import.meta.dir, "..", "fixtures", "e2e"); +const snapshotDir = join(fixtureDir, "snapshots", "report"); +const repoRoot = join(import.meta.dir, "..", ".."); +const cases = JSON.parse(readFileSync(join(fixtureDir, "report_cases.json"), "utf8")) as ReportCase[]; + +function normalizeReportPayload(payload: Record): Record { + const normalized = structuredClone(payload) as { analyze?: { input?: { path?: string } } }; + const pathValue = normalized.analyze?.input?.path; + if (typeof pathValue === "string") { + const normalizedSlashes = pathValue.replaceAll("\\", "/"); + if (/^[A-Za-z]:\//.test(normalizedSlashes)) { + normalized.analyze!.input!.path = normalizedSlashes.replace(/^.*tests\/fixtures\//, "tests/fixtures/"); + } else if (isAbsolute(pathValue)) { + normalized.analyze!.input!.path = relative(repoRoot, pathValue).replaceAll("\\", "/"); + } else { + normalized.analyze!.input!.path = normalizedSlashes; + } + } + return normalized as Record; +} + +describe("report e2e snapshots", () => { + for (const scenario of cases) { + test(scenario.id, async () => { + const result = await runReportCli(scenario.args); + expect(result.exitCode).toBe(0); + + const expected = normalizeReportPayload( + JSON.parse(readFileSync(join(snapshotDir, `${scenario.id}.json`), "utf8")) as Record, + ); + const actual = normalizeReportPayload(parseJsonStdout(result.stdout, scenario.id)); + expect(actual).toEqual(expected); + }); + } +}); diff --git a/tests/fixtures/e2e/generate-report-snapshots.ts b/tests/fixtures/e2e/generate-report-snapshots.ts new file mode 100644 index 0000000..8ab6ca8 --- /dev/null +++ b/tests/fixtures/e2e/generate-report-snapshots.ts @@ -0,0 +1,64 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { isAbsolute, join, relative } from "node:path"; + +type ReportCase = { + id: string; + args: string[]; +}; + +const fixturesDir = import.meta.dir; +const repoRoot = join(fixturesDir, "..", "..", ".."); +const snapshotDir = join(fixturesDir, "snapshots", "report"); +mkdirSync(snapshotDir, { recursive: true }); + +const cases = JSON.parse(readFileSync(join(fixturesDir, "report_cases.json"), "utf8")) as ReportCase[]; + +function normalizeReportPath(pathValue: string): string { + const normalizedSlashes = pathValue.replaceAll("\\", "/"); + if (/^[A-Za-z]:\//.test(normalizedSlashes)) { + return normalizedSlashes.replace(/^.*tests\/fixtures\//, "tests/fixtures/"); + } + if (isAbsolute(pathValue)) { + return relative(repoRoot, pathValue).replaceAll("\\", "/"); + } + return normalizedSlashes; +} + +for (const testCase of cases) { + const proc = Bun.spawnSync({ + cmd: ["bun", "run", "report", ...testCase.args], + cwd: repoRoot, + stdout: "pipe", + stderr: "pipe", + }); + + if (proc.exitCode !== 0) { + throw new Error(`failed for ${testCase.id}: ${proc.stderr.toString()}`); + } + + const lines = proc.stdout + .toString() + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + const payload = lines[lines.length - 1]; + if (!payload) { + throw new Error(`no json payload for ${testCase.id}`); + } + let parsed: Record; + try { + parsed = JSON.parse(payload) as Record; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`invalid json payload for ${testCase.id}: ${message}; payload=${payload}`); + } + + const analyze = parsed.analyze as { input?: { path?: string } } | undefined; + if (typeof analyze?.input?.path === "string") { + analyze.input.path = normalizeReportPath(analyze.input.path); + } + + writeFileSync(join(snapshotDir, `${testCase.id}.json`), `${JSON.stringify(parsed)}\n`, "utf8"); +} + +console.log(`generated ${cases.length} report snapshots`); diff --git a/tests/fixtures/e2e/report_cases.json b/tests/fixtures/e2e/report_cases.json new file mode 100644 index 0000000..d40b75e --- /dev/null +++ b/tests/fixtures/e2e/report_cases.json @@ -0,0 +1,14 @@ +[ + { + "id": "report-reliable-feed-portrait-png", + "args": ["tests/fixtures/images/portrait_sample_30x40.png", "--mode", "reliable", "--surface", "feed", "--workflow", "unknown", "--json"] + }, + { + "id": "report-reliable-reel-portrait-mp4-audio", + "args": ["tests/fixtures/images/portrait_video_audio_360x640.mp4", "--mode", "reliable", "--surface", "reel", "--workflow", "unknown", "--json"] + }, + { + "id": "report-reliable-feed-landscape-white", + "args": ["tests/fixtures/images/landscape_sample_48x32.jpg", "--mode", "reliable", "--surface", "feed", "--workflow", "unknown", "--white-canvas", "--json"] + } +] diff --git a/tests/fixtures/e2e/snapshots/report/report-reliable-feed-landscape-white.json b/tests/fixtures/e2e/snapshots/report/report-reliable-feed-landscape-white.json new file mode 100644 index 0000000..878352f --- /dev/null +++ b/tests/fixtures/e2e/snapshots/report/report-reliable-feed-landscape-white.json @@ -0,0 +1 @@ +{"analyze":{"input":{"aspect_ratio":"1.5000","audio_bitrate_kbps":null,"audio_channels":null,"audio_codec":null,"audio_sample_rate_hz":null,"bitrate_kbps":null,"codec":null,"colorspace":"unknown","duration_seconds":null,"fps":0,"has_audio":false,"height":32,"orientation":"landscape","path":"tests/fixtures/images/landscape_sample_48x32.jpg","width":48},"selection":{"mode":"reliable","profile":"reliable_feed_white_canvas_feed_compat","surface":"feed","target_resolution":"1080x1350","workflow":"unknown"},"tier":{"name":"tier_upscale","reason":"Input width is below 320 and may be upscaled by Instagram.","risk_level":"high"},"white_canvas":{"contain_only":true,"enabled":true,"margins":{"bottom":216,"left":43,"right":43,"top":216},"no_crop":true,"profile":"feed_compat"}},"checks":[{"id":"input_width_min","label":"Input width baseline","message":"Input width 48px is below baseline threshold (320px).","status":"warn"},{"id":"aspect_fit","label":"Aspect fit","message":"Input aspect is within supported bounds for selected surface.","status":"pass"},{"id":"audio_present","label":"Audio track","message":"Still image input: audio track is not applicable.","status":"pass"},{"id":"codec_preference","label":"Codec preference","message":"Still image input: codec preference is not applicable.","status":"pass"}],"next_actions":["Use export-image for deterministic still export.","Review warning checks before upload.","Confirm white-canvas margins visually before posting."]} diff --git a/tests/fixtures/e2e/snapshots/report/report-reliable-feed-portrait-png.json b/tests/fixtures/e2e/snapshots/report/report-reliable-feed-portrait-png.json new file mode 100644 index 0000000..4204fc8 --- /dev/null +++ b/tests/fixtures/e2e/snapshots/report/report-reliable-feed-portrait-png.json @@ -0,0 +1 @@ +{"analyze":{"input":{"aspect_ratio":"0.7500","audio_bitrate_kbps":null,"audio_channels":null,"audio_codec":null,"audio_sample_rate_hz":null,"bitrate_kbps":null,"codec":null,"colorspace":"unknown","duration_seconds":null,"fps":0,"has_audio":false,"height":40,"orientation":"portrait","path":"tests/fixtures/images/portrait_sample_30x40.png","width":30},"selection":{"mode":"reliable","profile":"reliable_feed_portrait_safe","surface":"feed","target_resolution":"1080x1350","workflow":"unknown"},"tier":{"name":"tier_aspect_correction","reason":"Aspect ratio 0.7500 is outside supported feed bounds.","risk_level":"medium"},"white_canvas":{"contain_only":false,"enabled":false,"margins":null,"no_crop":false,"profile":null}},"checks":[{"id":"input_width_min","label":"Input width baseline","message":"Input width 30px is below baseline threshold (320px).","status":"warn"},{"id":"aspect_fit","label":"Aspect fit","message":"Input aspect is outside supported bounds for selected surface.","status":"warn"},{"id":"audio_present","label":"Audio track","message":"Still image input: audio track is not applicable.","status":"pass"},{"id":"codec_preference","label":"Codec preference","message":"Still image input: codec preference is not applicable.","status":"pass"}],"next_actions":["Use export-image for deterministic still export.","Review warning checks before upload."]} diff --git a/tests/fixtures/e2e/snapshots/report/report-reliable-reel-portrait-mp4-audio.json b/tests/fixtures/e2e/snapshots/report/report-reliable-reel-portrait-mp4-audio.json new file mode 100644 index 0000000..53cdd33 --- /dev/null +++ b/tests/fixtures/e2e/snapshots/report/report-reliable-reel-portrait-mp4-audio.json @@ -0,0 +1 @@ +{"analyze":{"input":{"aspect_ratio":"0.5625","audio_bitrate_kbps":97,"audio_channels":1,"audio_codec":"aac","audio_sample_rate_hz":48000,"bitrate_kbps":186,"codec":"h264","colorspace":"unknown","duration_seconds":1,"fps":30,"has_audio":true,"height":640,"orientation":"portrait","path":"tests/fixtures/images/portrait_video_audio_360x640.mp4","width":360},"selection":{"mode":"reliable","profile":"reliable_reel_safe","surface":"reel","target_resolution":"1080x1920","workflow":"unknown"},"tier":{"name":"tier_preserve","reason":"Input width is within 320..1080 and aspect is supported.","risk_level":"low"},"white_canvas":{"contain_only":false,"enabled":false,"margins":null,"no_crop":false,"profile":null}},"checks":[{"id":"input_width_min","label":"Input width baseline","message":"Input width 360px is within baseline threshold.","status":"pass"},{"id":"aspect_fit","label":"Aspect fit","message":"Input aspect is within supported bounds for selected surface.","status":"pass"},{"id":"audio_present","label":"Audio track","message":"Audio track detected in input video.","status":"pass"},{"id":"codec_preference","label":"Codec preference","message":"Input codec is h264 and matches baseline preference.","status":"pass"}],"next_actions":["Use export-video for deterministic video export."]} diff --git a/tests/helpers/cli.ts b/tests/helpers/cli.ts index a5f45ce..f48a8ea 100644 --- a/tests/helpers/cli.ts +++ b/tests/helpers/cli.ts @@ -9,7 +9,7 @@ export type CliRunResult = { const REPO_ROOT = resolve(import.meta.dir, "..", ".."); async function runCli( - command: "recommend" | "analyze" | "export-image" | "export-video", + command: "recommend" | "analyze" | "export-image" | "export-video" | "report", args: string[], ): Promise { const proc = Bun.spawn({ @@ -44,6 +44,10 @@ export async function runExportVideoCli(args: string[]): Promise { return runCli("export-video", args); } +export async function runReportCli(args: string[]): Promise { + return runCli("report", args); +} + export function parseJsonStdout(stdout: string, context = "stdout"): Record { const lines = stdout .split("\n") diff --git a/tests/integration/report.integration.test.ts b/tests/integration/report.integration.test.ts new file mode 100644 index 0000000..be8e0ab --- /dev/null +++ b/tests/integration/report.integration.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { parseJsonStdout, runReportCli } from "../helpers/cli"; + +const fixtures = join(import.meta.dir, "..", "fixtures", "images"); + +describe("report cli integration", () => { + test("returns report contract in json", async () => { + const file = join(fixtures, "portrait_sample_30x40.png"); + const result = await runReportCli([ + file, + "--mode", + "reliable", + "--surface", + "feed", + "--workflow", + "unknown", + "--json", + ]); + + expect(result.exitCode).toBe(0); + const payload = parseJsonStdout(result.stdout); + expect(payload).toHaveProperty("analyze"); + expect(payload).toHaveProperty("checks"); + expect(payload).toHaveProperty("next_actions"); + }); + + test("audio check passes for video with audio", async () => { + const file = join(fixtures, "portrait_video_audio_360x640.mp4"); + const result = await runReportCli([ + file, + "--mode", + "reliable", + "--surface", + "reel", + "--workflow", + "unknown", + "--json", + ]); + + expect(result.exitCode).toBe(0); + const payload = parseJsonStdout(result.stdout); + const checks = payload.checks as Array>; + const audioCheck = checks.find((item) => item.id === "audio_present"); + expect(audioCheck).toBeDefined(); + expect(audioCheck?.status).toBe("pass"); + }); + + test("audio check warns for video without audio", async () => { + const file = join(fixtures, "portrait_video_360x640.mp4"); + const result = await runReportCli([ + file, + "--mode", + "reliable", + "--surface", + "reel", + "--workflow", + "unknown", + "--json", + ]); + + expect(result.exitCode).toBe(0); + const payload = parseJsonStdout(result.stdout); + const checks = payload.checks as Array>; + const audioCheck = checks.find((item) => item.id === "audio_present"); + expect(audioCheck).toBeDefined(); + expect(audioCheck?.status).toBe("warn"); + }); +});