dbbb051ebd
Build & push images / images (push) Successful in 4m22s
The wash stream is small; the entry camera photographs every car in exactly the view the classifier is trained on. The booth can now queue entry vehicle reads as pure training material — crop + the camera's class, no order, no operator, no category. - Core announces every vehicle read (deviceEvents.emitVehicleRead from snapshot.ts); the Car Wash module listens, samples entry reads in-process (sampleEntry: exactly one in N) and queues them (enqueueEntry). CARWASH_REVIEW_ENTRY_SAMPLE=N; 1 = every entry (storage and bandwidth are not the limit — user); 0/unset = off. Forwarded by compose. - Packages carry kind: "wash" | "entry". Collector: kind column, entry meta validated without the operator fields, review screen shows an entry sample as such, export has a kind column, operator agreement computed from wash items only. Setup line shows "1 in N entries sampled"; status carries entrySample. - komodo: park-2's four review lines enabled (collector URL by Netbird DNS name, booth-2, the shared per-booth secret, every entry sampled) — the collector is up on the overlay. - Tests on both sides. Wiki: vision-review-outbox (entry stream + the internet-feed assessment), log. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
242 lines
14 KiB
TypeScript
242 lines
14 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
import sharp from "sharp";
|
||
import { createTestDb } from "@parking/db/testing";
|
||
import { carwashOrders, carwashReviewOutbox, deviceEvents, snapshots, type Db } from "@parking/db";
|
||
import type { FastifyInstance } from "fastify";
|
||
import { deviceEvents as deviceEventBus } from "../../device-events.js";
|
||
import { buildServer } from "../../server.js";
|
||
import { login, makeLog, minutesAgo, seedTariff, seedUser, silentLogger } from "../../test-helpers.js";
|
||
import { EXPIRE_DAYS, ReviewOutbox, makeReviewCrop, operatorRef, reviewUploadConfigFromEnv } from "./review-outbox.js";
|
||
|
||
// The review outbox, booth side (wiki/concepts/vision-review-outbox.md): a plate-blurred
|
||
// vehicle crop + the operator's choice, queued off the intake path, drained one-way with
|
||
// backoff, never blocking the wash, never naming the site.
|
||
|
||
/** A 400×300 frame: grey ground, a red "car" block, a white "plate" strip inside it. */
|
||
async function frame(): Promise<Buffer> {
|
||
return sharp({ create: { width: 400, height: 300, channels: 3, background: { r: 90, g: 90, b: 90 } } })
|
||
.composite([
|
||
{ input: { create: { width: 200, height: 120, channels: 3, background: { r: 200, g: 30, b: 30 } } }, left: 100, top: 100 },
|
||
{ input: { create: { width: 60, height: 16, channels: 3, background: { r: 255, g: 255, b: 255 } } }, left: 170, top: 190 },
|
||
])
|
||
.jpeg()
|
||
.toBuffer();
|
||
}
|
||
const CAR = { x1: 100 / 400, y1: 100 / 300, x2: 300 / 400, y2: 220 / 300 };
|
||
const PLATE = { x1: 170 / 400, y1: 190 / 300, x2: 230 / 400, y2: 206 / 300 };
|
||
|
||
/** Mean GREEN over a region — the white plate reads 255, the red car around it 30, so a
|
||
* blurred plate drops far below 255 as the red bleeds in. */
|
||
async function meanGreen(buf: Buffer, region: { left: number; top: number; width: number; height: number }): Promise<number> {
|
||
const { data, info } = await sharp(buf).extract(region).raw().toBuffer({ resolveWithObject: true });
|
||
let sum = 0;
|
||
for (let i = 1; i < data.length; i += info.channels) sum += data[i]!;
|
||
return sum / (data.length / info.channels);
|
||
}
|
||
|
||
describe("makeReviewCrop", () => {
|
||
it("cuts the vehicle (with margin), blurs the plate inside it, caps the edge", async () => {
|
||
const shot = await frame();
|
||
const crop = await makeReviewCrop(shot, CAR, PLATE);
|
||
expect(crop.plateBlurred).toBe(true);
|
||
// Box 200×120 + 8 % margin each side ≈ 232×139; no upscaling.
|
||
expect(crop.width).toBeGreaterThanOrEqual(228);
|
||
expect(crop.width).toBeLessThanOrEqual(236);
|
||
expect(crop.height).toBeGreaterThanOrEqual(135);
|
||
// The white plate is gone: over the plate strip (crop coords: the frame's 170..230 ×
|
||
// 190..206 shifted by the crop origin 84,90) the same region cut straight from the
|
||
// frame is white, the review crop is the red bleeding in.
|
||
const plain = await sharp(shot).extract({ left: 84, top: 90, width: crop.width, height: crop.height }).jpeg().toBuffer();
|
||
const strip = { left: 170 - 84, top: 190 - 90, width: 60, height: 16 };
|
||
expect(await meanGreen(plain, strip)).toBeGreaterThan(240);
|
||
expect(await meanGreen(crop.bytes, strip)).toBeLessThan(180);
|
||
// Without a plate box: same crop, nothing blurred.
|
||
const noPlate = await makeReviewCrop(shot, CAR, null);
|
||
expect(noPlate.plateBlurred).toBe(false);
|
||
// A big frame is capped to the max edge.
|
||
const big = await sharp({ create: { width: 2560, height: 1440, channels: 3, background: "#444" } }).jpeg().toBuffer();
|
||
const capped = await makeReviewCrop(big, { x1: 0, y1: 0, x2: 1, y2: 1 }, null);
|
||
expect(Math.max(capped.width, capped.height)).toBe(640);
|
||
});
|
||
});
|
||
|
||
describe("config + pseudonyms", () => {
|
||
it("needs url, token and booth id together; the operator ref is a keyed hash", () => {
|
||
expect(reviewUploadConfigFromEnv({})).toBeNull();
|
||
expect(reviewUploadConfigFromEnv({ CARWASH_REVIEW_URL: "https://c/ingest", CARWASH_REVIEW_TOKEN: "t" })).toBeNull();
|
||
const cfg = reviewUploadConfigFromEnv({ CARWASH_REVIEW_URL: "https://c/ingest", CARWASH_REVIEW_TOKEN: "t", CARWASH_REVIEW_BOOTH_ID: "b7", CARWASH_REVIEW_INTERVAL_SEC: "5" });
|
||
expect(cfg).toMatchObject({ boothId: "b7", intervalSec: 60 }); // below the 10 s floor → default
|
||
expect(operatorRef("b7", "lavazhier")).toHaveLength(16);
|
||
expect(operatorRef("b7", "lavazhier")).not.toBe(operatorRef("b8", "lavazhier"));
|
||
expect(operatorRef("b7", "lavazhier")).not.toContain("lavazhier");
|
||
});
|
||
});
|
||
|
||
describe("queue + drain", () => {
|
||
let db: Db;
|
||
let close: () => void;
|
||
beforeEach(() => {
|
||
const t = createTestDb();
|
||
db = t.db;
|
||
close = t.close;
|
||
});
|
||
afterEach(() => close());
|
||
|
||
const cfg = { url: "https://collector.overlay/ingest", token: "secret-1", boothId: "booth-7", intervalSec: 60, entrySample: 0 };
|
||
const read = { bodyType: "car" as const, confidence: 0.86, snapshotId: "snap-1", box: CAR, plateBox: PLATE };
|
||
const item = { orderId: "o-1", createdAt: "2026-09-06T10:00:00.000Z", createdBy: "lavazhier", categoryId: "car", categoryName: "Vetura", categoryClasses: ["car", "sedan"], serviceName: "Standard", visionCategoryId: "car", downgraded: false };
|
||
|
||
async function seed(): Promise<void> {
|
||
db.insert(snapshots).values({ id: "snap-1", direction: "entry", identity: "T-1", contentType: "image/jpeg", bytes: await frame(), capturedAt: new Date().toISOString() }).run();
|
||
db.insert(carwashOrders).values({
|
||
id: "o-1", identity: "T-1", plate: null, categoryId: "car", categoryName: "Vetura", serviceId: "std", serviceName: "Standard",
|
||
priceMinor: 100, currency: "ALL", payAt: "booth", status: "open", createdAt: item.createdAt, createdBy: "lavazhier",
|
||
}).run();
|
||
}
|
||
|
||
it("enqueues a crop + a payload with no site name, no plate, no operator name; drains with a multipart POST; drops the image once sent", async () => {
|
||
await seed();
|
||
const calls: { url: string; init: RequestInit }[] = [];
|
||
const fetchFn = vi.fn(async (url: string, init: RequestInit) => {
|
||
calls.push({ url, init });
|
||
return new Response("ok", { status: 200 });
|
||
});
|
||
const ob = new ReviewOutbox(db, silentLogger(), cfg, fetchFn);
|
||
expect(await ob.enqueue(item, read)).toBe(true);
|
||
const row = db.select().from(carwashReviewOutbox).all()[0]!;
|
||
expect(row.status).toBe("queued");
|
||
expect(row.image!.length).toBeGreaterThan(500);
|
||
expect(row.payload).toMatchObject({ v: 1, kind: "wash", booth: "booth-7", order: "o-1", operatorCategory: { id: "car", name: "Vetura", classes: ["car", "sedan"] }, vision: { class: "car", confidence: 0.86 }, downgraded: false, image: { plateBlurred: true } });
|
||
expect(JSON.stringify(row.payload)).not.toContain("lavazhier");
|
||
|
||
expect(await ob.drain()).toEqual({ sent: 1, failed: 0, deferred: 0 });
|
||
expect(calls).toHaveLength(1);
|
||
expect(calls[0]!.url).toBe(cfg.url);
|
||
expect((calls[0]!.init.headers as Record<string, string>).authorization).toBe("Bearer secret-1");
|
||
const form = calls[0]!.init.body as FormData;
|
||
expect(JSON.parse(form.get("meta") as string).item).toBe(row.id);
|
||
expect((form.get("image") as File).type).toBe("image/jpeg");
|
||
const after = db.select().from(carwashReviewOutbox).all()[0]!;
|
||
expect(after.status).toBe("sent");
|
||
expect(after.image).toBeNull();
|
||
expect(after.sentAt).toBeTruthy();
|
||
expect(ob.status()).toMatchObject({ enabled: true, boothId: "booth-7", queued: 0, sent: 1, failed: 0 });
|
||
});
|
||
|
||
it("defers with backoff on collector/network trouble, abandons on a rejection, a void or expiry, skips without a box", async () => {
|
||
await seed();
|
||
let status = 503;
|
||
const fetchFn = vi.fn(async () => (status === 0 ? Promise.reject(new Error("ECONNREFUSED")) : new Response("", { status })));
|
||
const ob = new ReviewOutbox(db, silentLogger(), cfg, fetchFn);
|
||
await ob.enqueue(item, read);
|
||
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 1 });
|
||
let row = db.select().from(carwashReviewOutbox).all()[0]!;
|
||
expect(row).toMatchObject({ status: "queued", attempts: 1, lastError: "HTTP 503" });
|
||
expect(Date.parse(row.nextAttemptAt!)).toBeGreaterThan(Date.now() + 60_000);
|
||
// Not due yet → untouched.
|
||
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 0 });
|
||
// Due again: a network error defers too; a 422 abandons.
|
||
db.update(carwashReviewOutbox).set({ nextAttemptAt: null }).run();
|
||
status = 0;
|
||
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 1 });
|
||
db.update(carwashReviewOutbox).set({ nextAttemptAt: null }).run();
|
||
status = 422;
|
||
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
|
||
row = db.select().from(carwashReviewOutbox).all()[0]!;
|
||
expect(row).toMatchObject({ status: "failed", lastError: "rejected: HTTP 422" });
|
||
expect(row.image).toBeNull();
|
||
|
||
// A voided order is not a sample.
|
||
status = 200;
|
||
await ob.enqueue({ ...item, orderId: "o-1" }, read);
|
||
db.update(carwashOrders).set({ status: "void" }).run();
|
||
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
|
||
// Expired items are abandoned without a request.
|
||
await ob.enqueue(item, read);
|
||
db.update(carwashReviewOutbox).set({ createdAt: new Date(Date.now() - (EXPIRE_DAYS + 1) * 86_400_000).toISOString() }).where(eq(carwashReviewOutbox.status, "queued")).run();
|
||
db.update(carwashOrders).set({ status: "open" }).run();
|
||
const before = fetchFn.mock.calls.length;
|
||
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
|
||
expect(fetchFn.mock.calls.length).toBe(before);
|
||
expect(ob.status().failed).toBe(3);
|
||
|
||
// Entry sampling: one in N entry reads becomes a package with the crop and the
|
||
// camera's class only — no order, no operator, no category.
|
||
const sampler = new ReviewOutbox(db, silentLogger(), { ...cfg, entrySample: 3 }, fetchFn);
|
||
expect([sampler.sampleEntry(), sampler.sampleEntry(), sampler.sampleEntry(), sampler.sampleEntry()]).toEqual([false, false, true, false]);
|
||
expect(ob.sampleEntry()).toBe(false); // entrySample 0 = off
|
||
expect(await sampler.enqueueEntry(read)).toBe(true);
|
||
const entryRow = db.select().from(carwashReviewOutbox).where(eq(carwashReviewOutbox.orderId, "entry:snap-1")).get()!;
|
||
expect(entryRow.payload).toMatchObject({ v: 1, kind: "entry", booth: "booth-7", vision: { class: "car", confidence: 0.86 }, image: { plateBlurred: true } });
|
||
expect(entryRow.payload).not.toHaveProperty("operator");
|
||
expect(entryRow.payload).not.toHaveProperty("operatorCategory");
|
||
expect(entryRow.image!.length).toBeGreaterThan(500);
|
||
|
||
// No vehicle box, no snapshot, or upload off → nothing queued.
|
||
expect(await ob.enqueue(item, { ...read, box: null })).toBe(false);
|
||
expect(await ob.enqueue(item, { ...read, snapshotId: "gone" })).toBe(false);
|
||
expect(await new ReviewOutbox(db, silentLogger(), null, fetchFn).enqueue(item, read)).toBe(false);
|
||
});
|
||
});
|
||
|
||
import { eq } from "@parking/db";
|
||
|
||
describe("through the app", () => {
|
||
let db: Db;
|
||
let close: () => void;
|
||
let app: FastifyInstance;
|
||
const saved = { ...process.env };
|
||
beforeEach(async () => {
|
||
delete process.env.MODULES_ENTITLED;
|
||
process.env.CARWASH_REVIEW_URL = "https://collector.overlay/ingest";
|
||
process.env.CARWASH_REVIEW_TOKEN = "tok";
|
||
process.env.CARWASH_REVIEW_BOOTH_ID = "booth-9";
|
||
process.env.CARWASH_REVIEW_ENTRY_SAMPLE = "1";
|
||
const t = createTestDb();
|
||
db = t.db;
|
||
close = t.close;
|
||
app = await buildServer({ db });
|
||
await app.ready();
|
||
});
|
||
afterEach(async () => {
|
||
await app.close();
|
||
close();
|
||
for (const k of ["CARWASH_REVIEW_URL", "CARWASH_REVIEW_TOKEN", "CARWASH_REVIEW_BOOTH_ID", "CARWASH_REVIEW_ENTRY_SAMPLE"]) {
|
||
if (saved[k] === undefined) delete process.env[k];
|
||
else process.env[k] = saved[k];
|
||
}
|
||
});
|
||
|
||
it("a wash intake with a vehicle read queues a review item; the status route reports it", async () => {
|
||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||
const a = await login(app, username, password);
|
||
const hdrs = { cookie: a.cookie, "x-csrf-token": a.csrf };
|
||
seedTariff(db);
|
||
const s = (await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs, payload: { categories: [{ name: "Vetura", visionClasses: ["car"] }], services: [{ name: "Standard" }], prices: [] } })).json();
|
||
const cat = s.categories[0].id, svc = s.services[0].id;
|
||
await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs, payload: { prices: [{ categoryId: cat, serviceId: svc, priceMinor: 500 }] } });
|
||
await makeLog(db).append({ type: "vehicle_entry", source: "manual", identity: "T-R", occurredAt: minutesAgo(30), payload: { sessionRef: "T-R", category: "default" } });
|
||
db.insert(snapshots).values({ id: "snap-r", direction: "entry", identity: "T-R", contentType: "image/jpeg", bytes: await frame(), capturedAt: new Date().toISOString() }).run();
|
||
db.insert(deviceEvents).values({
|
||
id: "read-r", deviceId: "cam-1", category: "camera", kind: "read",
|
||
detail: { identity: "T-R", direction: "entry", bodyType: "car", bodyConfidence: 0.9, snapshotId: "snap-r", vehicleBox: CAR, plateBox: PLATE },
|
||
occurredAt: new Date().toISOString(),
|
||
}).run();
|
||
|
||
const order = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs, payload: { identity: "T-R", categoryId: cat, serviceId: svc } });
|
||
expect(order.statusCode).toBe(201);
|
||
// Enqueue is fire-and-forget: give the crop a moment.
|
||
await vi.waitFor(() => expect(db.select().from(carwashReviewOutbox).all()).toHaveLength(1));
|
||
const status = (await app.inject({ method: "GET", url: "/api/carwash/review/status", headers: { cookie: a.cookie } })).json();
|
||
expect(status).toMatchObject({ enabled: true, boothId: "booth-9", queued: 1, sent: 0, entrySample: 1 });
|
||
|
||
// An ENTRY vehicle read announced by the core (snapshot.ts) is sampled by the module
|
||
// (1 in 1 here) into an entry package; an exit read is not.
|
||
deviceEventBus.emitVehicleRead({ identity: "T-X", direction: "exit", read: { bodyType: "car", confidence: 0.8, snapshotId: "snap-r", box: CAR, plateBox: PLATE } });
|
||
deviceEventBus.emitVehicleRead({ identity: "T-R", direction: "entry", read: { bodyType: "car", confidence: 0.8, snapshotId: "snap-r", box: CAR, plateBox: PLATE } });
|
||
await vi.waitFor(() => expect(db.select().from(carwashReviewOutbox).all()).toHaveLength(2));
|
||
const rows = db.select().from(carwashReviewOutbox).all();
|
||
expect(rows.map((r) => (r.payload as { kind: string }).kind).sort()).toEqual(["entry", "wash"]);
|
||
});
|
||
});
|