Brings the working codebase (Next.js app, auth system, Stripe billing, Docker/deploy config, tests, docs) into version control on top of the placeholder initial commit, and adds account self-deletion (Danger Zone in Settings, password + typed-email confirmation, cascading DB cleanup, Stripe cancellation) per GDPR right-to-erasure. Excludes local build caches, node_modules, and internal agent scratch files; .gitignore hardened to keep those out going forward. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
116 lines
3.6 KiB
TypeScript
116 lines
3.6 KiB
TypeScript
/**
|
||
* Sprint C: image quality — min width, contrast gate, crop box, deskew.
|
||
*/
|
||
|
||
import { describe, test, expect } from "./runner";
|
||
import {
|
||
AI_IMAGE_LONG_EDGE_PX,
|
||
AI_IMAGE_MIN_WIDTH_PX,
|
||
computeReceiptResize,
|
||
} from "../../src/lib/image/resize";
|
||
import {
|
||
contentBoundingBox,
|
||
estimateSkewDegrees,
|
||
needsContrastBoost,
|
||
} from "../../src/lib/image/enhance";
|
||
import { processReceiptImage } from "../../src/lib/image/processor";
|
||
|
||
describe("Sprint C — Mindestbreite", () => {
|
||
test("Querformat bleibt am 1536-Long-Edge", () => {
|
||
const out = computeReceiptResize(2000, 1400);
|
||
expect(out.width).toBe(1536);
|
||
expect(out.height).toBe(1075);
|
||
});
|
||
|
||
test("quadratische kleine Fotos werden nicht hochskaliert", () => {
|
||
expect(computeReceiptResize(400, 400)).toEqual({ width: 400, height: 400 });
|
||
});
|
||
|
||
test("hoher Thermobon wird breiter als bei reinem Long-Edge-Cap", () => {
|
||
const srcW = 800;
|
||
const srcH = 3500;
|
||
const longEdgeOnlyW = Math.round(srcW * (AI_IMAGE_LONG_EDGE_PX / srcH));
|
||
const out = computeReceiptResize(srcW, srcH);
|
||
expect(longEdgeOnlyW).toBeLessThan(400);
|
||
expect(out.width).toBeGreaterThan(longEdgeOnlyW);
|
||
expect(out.width).toBeGreaterThanOrEqual(650);
|
||
expect(Math.max(out.width, out.height)).toBeLessThanOrEqual(4096);
|
||
});
|
||
|
||
test("Min-Width-Konstante ist 1100", () => {
|
||
expect(AI_IMAGE_MIN_WIDTH_PX).toBe(1100);
|
||
});
|
||
});
|
||
|
||
describe("Sprint C — Kontrast", () => {
|
||
test("verwaschenes Thermobild (niedrige Stdev) bekommt Boost", () => {
|
||
expect(
|
||
needsContrastBoost({
|
||
channels: [{ mean: 170, stdev: 12, min: 140, max: 200 }],
|
||
})
|
||
).toBe(true);
|
||
});
|
||
|
||
test("farbstarkes Foto bekommt keinen Boost", () => {
|
||
expect(
|
||
needsContrastBoost({
|
||
channels: [
|
||
{ mean: 90, stdev: 55, min: 10, max: 240 },
|
||
{ mean: 100, stdev: 60, min: 8, max: 250 },
|
||
{ mean: 80, stdev: 50, min: 5, max: 230 },
|
||
],
|
||
})
|
||
).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("Sprint C — Crop", () => {
|
||
test("dunkles Rechteck auf weißem Grund wird als Inhalt erkannt", () => {
|
||
const w = 80;
|
||
const h = 80;
|
||
const data = new Uint8Array(w * h).fill(250);
|
||
for (let y = 18; y < 62; y++) {
|
||
for (let x = 16; x < 64; x++) {
|
||
data[y * w + x] = 20;
|
||
}
|
||
}
|
||
const box = contentBoundingBox(data, w, h);
|
||
expect(box).not.toBeNull();
|
||
expect(box!.left).toBeLessThanOrEqual(16);
|
||
expect(box!.top).toBeLessThanOrEqual(18);
|
||
expect(box!.left + box!.width).toBeGreaterThanOrEqual(64);
|
||
expect(box!.top + box!.height).toBeGreaterThanOrEqual(62);
|
||
});
|
||
|
||
test("einfarbiges Bild wird nicht beschnitten", () => {
|
||
const data = new Uint8Array(40 * 40).fill(200);
|
||
expect(contentBoundingBox(data, 40, 40)).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("Sprint C — Deskew", () => {
|
||
test("waagerechte Textzeilen → Winkel ~ 0", () => {
|
||
const w = 64;
|
||
const h = 80;
|
||
const data = new Uint8Array(w * h);
|
||
for (let y = 0; y < h; y++) {
|
||
const dark = y % 8 < 3;
|
||
data.fill(dark ? 15 : 230, y * w, y * w + w);
|
||
}
|
||
expect(Math.abs(estimateSkewDegrees(data, w, h))).toBeLessThan(1);
|
||
});
|
||
});
|
||
|
||
describe("Sprint C — Pipeline", () => {
|
||
test("1×1-PNG überlebt Deskew/Crop/Resize", async () => {
|
||
const png = Buffer.from(
|
||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||
"base64"
|
||
);
|
||
const result = await processReceiptImage(png, "image/png");
|
||
expect(result.mimeType).toBe("image/jpeg");
|
||
expect(result.width).toBeGreaterThan(0);
|
||
expect(result.height).toBeGreaterThan(0);
|
||
});
|
||
});
|