pixel8/tests/phase3.test.js

239 lines
13 KiB
JavaScript

"use strict";
const { test, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { loadConfig } = require("../src/config");
const { Engine } = require("../src/engine");
const { openStore } = require("../src/store");
const { QueueController } = require("../src/queue");
const { Scheduler } = require("../src/scheduler");
const { solidSource } = require("../src/sources");
const { solid } = require("../src/frame");
const { parseDurationMs, localToUtcIso } = require("../src/timeutil");
const { createServer } = require("../src/server");
const T0 = Date.UTC(2026, 10, 4, 12, 0, 0); // 2026-11-04T12:00:00Z
function makeHarness() {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pixel8-p3-"));
const store = openStore(path.join(tmpDir, "t.db"));
const engine = new Engine({ fps: 10 });
engine.setSource(solidSource([0, 0, 0]), "test:black");
const queue = new QueueController({ store, engine, defaultItemMs: 1000, fps: 10 });
let now = T0;
const scheduler = new Scheduler({ store, engine, queue, timezone: "UTC", fps: 10, now: () => now });
return {
store, engine, queue, scheduler, tmpDir,
get now() { return now; },
advance(ms) { now += ms; return now; },
cleanup() { store.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); },
};
}
function addApproved(store, title, color = [10, 20, 30]) {
const id = `art-${title.toLowerCase().replace(/\s+/g, "-")}`;
store.db.prepare("INSERT INTO artworks (id,title,artist_name,approval_status,format,content_hash,approved_at) VALUES (?,?,?,'approved','png',?,datetime('now'))")
.run(id, title, `${title} Artist`, `hash-${id}`);
store.db.prepare("INSERT INTO artwork_cache (artwork_id, frames_json, frame_count, source_kind) VALUES (?,?,?,?)")
.run(id, JSON.stringify([solid(color)]), 1, "image");
return id;
}
function addExhibition(store, { artworkId, startsMs, durationMs, priority = 0, status = "scheduled", statePolicy = "loop" }) {
const id = `ex-${Math.random().toString(36).slice(2, 10)}`;
store.db.prepare(
`INSERT INTO exhibitions (id, artwork_id, starts_at_utc, ends_at_utc, duration_ms, timezone, priority, state_policy, status)
VALUES (?,?,?,?,?, 'UTC', ?, ?, ?)`
).run(id, artworkId, new Date(startsMs).toISOString(), new Date(startsMs + durationMs).toISOString(), durationMs, priority, statePolicy, status);
return id;
}
test("timeutil: parseDurationMs and DST-aware localToUtcIso", () => {
assert.equal(parseDurationMs("30s"), 30_000);
assert.equal(parseDurationMs("30m"), 1_800_000);
assert.equal(parseDurationMs("30h"), 108_000_000);
assert.equal(parseDurationMs("30d"), 2_592_000_000);
assert.equal(parseDurationMs({ value: 30, unit: "d" }), 2_592_000_000);
assert.equal(parseDurationMs(45_000), 45_000);
assert.throws(() => parseDurationMs("30 parsecs"), /unknown duration unit/);
assert.throws(() => parseDurationMs(500), /at least 1 second/);
assert.equal(localToUtcIso("2026-07-01T12:00", "America/New_York"), "2026-07-01T16:00:00.000Z"); // EDT
assert.equal(localToUtcIso("2026-01-15T12:00", "America/New_York"), "2026-01-15T17:00:00.000Z"); // EST
assert.equal(localToUtcIso("2026-07-01T12:00", "UTC"), "2026-07-01T12:00:00.000Z");
});
test("exhibition window: plays, then completes with absolute end time", () => {
const h = makeHarness();
try {
const art = addApproved(h.store, "Sunrise");
const ex = addExhibition(h.store, { artworkId: art, startsMs: h.now, durationMs: 30_000 });
h.scheduler.tick();
assert.equal(h.scheduler.currentId, ex);
assert.match(h.engine.sourceLabel, /^exhibition:/);
assert.equal(h.engine.currentMeta.title, "Sunrise");
// window passes -> completes; falls back to queue rotation (approved artwork)
h.advance(31_000);
h.scheduler.tick();
assert.equal(h.scheduler.currentId, null);
assert.equal(h.store.db.prepare("SELECT status FROM exhibitions WHERE id=?").get(ex).status, "completed");
assert.match(h.engine.sourceLabel, /^artwork:/); // queue rotation kicked in
} finally { h.cleanup(); }
});
test("restart recovery: end time and position survive a crash/restart (R-067/R-068)", () => {
const h = makeHarness();
try {
const art = addApproved(h.store, "Long 30d", [200, 0, 0]);
const THIRTY_DAYS = 2_592_000_000;
const ex = addExhibition(h.store, { artworkId: art, startsMs: h.now - 10_000, durationMs: THIRTY_DAYS });
h.scheduler.tick(); // enters exhibition at t0-? now=T0; exhibition started 10s before
h.advance(2_500); // 2.5 more seconds of playback
h.scheduler.tick(); // position persists (~once per second)
const endBefore = h.store.db.prepare("SELECT ends_at_utc FROM exhibitions WHERE id=?").get(ex).ends_at_utc;
const posBefore = h.store.db.prepare("SELECT position_ms FROM playback_state WHERE id=1").get().position_ms;
assert.ok(posBefore >= 2000, `persisted position ${posBefore} should be >= 2000`);
// simulate restart: brand new engine + scheduler on the same DB
const engine2 = new Engine({ fps: 10 });
engine2.setSource(solidSource([0, 0, 0]), "test:black");
const queue2 = new QueueController({ store: h.store, engine: engine2, defaultItemMs: 1000, fps: 10 });
const scheduler2 = new Scheduler({ store: h.store, engine: engine2, queue: queue2, timezone: "UTC", fps: 10, now: () => h.now });
scheduler2.start();
assert.equal(scheduler2.currentId, ex);
assert.match(engine2.sourceLabel, /^exhibition:/);
const endAfter = h.store.db.prepare("SELECT ends_at_utc FROM exhibitions WHERE id=?").get(ex).ends_at_utc;
assert.equal(endAfter, endBefore); // 30-day end time did NOT reset (acceptance criterion)
scheduler2.stop();
} finally { h.cleanup(); }
});
test("pause freezes frames and resume preserves remaining duration (end shifts by paused amount)", () => {
const h = makeHarness();
try {
const art = addApproved(h.store, "Pausable");
const ex = addExhibition(h.store, { artworkId: art, startsMs: h.now, durationMs: 10_000 });
h.scheduler.tick();
h.advance(2_000);
h.scheduler.tick();
assert.equal(h.scheduler.pause(ex).ok, true);
const frameAtPause = h.engine.currentFrame ? null : null; // frame integrity checked via positionMs
const posFrozen = h.scheduler.positionMs;
h.advance(3_000); // wall clock advances while paused
h.scheduler.tick();
assert.equal(h.scheduler.positionMs, posFrozen, "position must not advance while paused");
const before = h.store.db.prepare("SELECT ends_at_utc FROM exhibitions WHERE id=?").get(ex).ends_at_utc;
const res = h.scheduler.resume(ex);
assert.equal(res.ok, true);
assert.ok(res.extended_ms >= 2900, `end should shift by paused amount, got ${res.extended_ms}`);
const after = h.store.db.prepare("SELECT ends_at_utc FROM exhibitions WHERE id=?").get(ex).ends_at_utc;
assert.ok(Date.parse(after) > Date.parse(before), "ends_at_utc must move forward by the paused duration");
// playback continues from the pause point, not from zero
h.advance(500);
h.scheduler.tick();
assert.ok(h.scheduler.positionMs >= posFrozen && h.scheduler.positionMs < posFrozen + 1000);
} finally { h.cleanup(); }
});
test("skip completes an active exhibition immediately", () => {
const h = makeHarness();
try {
const art = addApproved(h.store, "Skippable");
const ex = addExhibition(h.store, { artworkId: art, startsMs: h.now, durationMs: 60_000 });
h.scheduler.tick();
assert.equal(h.scheduler.currentId, ex);
assert.equal(h.scheduler.skip(ex).ok, true);
assert.equal(h.scheduler.currentId, null);
assert.equal(h.store.db.prepare("SELECT status FROM exhibitions WHERE id=?").get(ex).status, "completed");
} finally { h.cleanup(); }
});
test("displaced exhibition is marked completed when a higher-priority window opens (regression)", () => {
const h = makeHarness();
try {
const big = addApproved(h.store, "Big Show", [1, 2, 3]);
const burst = addApproved(h.store, "Burst", [4, 5, 6]);
const bigEx = addExhibition(h.store, { artworkId: big, startsMs: h.now, durationMs: 30 * 86_400_000, priority: 1 });
const burstEx = addExhibition(h.store, { artworkId: burst, startsMs: h.now, durationMs: 2000, priority: 5 });
h.scheduler.tick();
assert.equal(h.engine.currentMeta.title, "Burst");
h.advance(3000); // burst window closes
h.scheduler.tick();
assert.equal(h.store.db.prepare("SELECT status FROM exhibitions WHERE id=?").get(burstEx).status, "completed", "burst exhibition must not linger as active");
assert.equal(h.scheduler.currentId, bigEx);
assert.equal(h.engine.currentMeta.title, "Big Show");
// and the big show eventually completes too, falling back to queue rotation
h.advance(30 * 86_400_000 + 1000);
h.scheduler.tick();
assert.equal(h.store.db.prepare("SELECT status FROM exhibitions WHERE id=?").get(bigEx).status, "completed");
} finally { h.cleanup(); }
});
test("priority rule: overlapping exhibitions resolve by priority (higher shows)", () => {
const h = makeHarness();
try {
const low = addApproved(h.store, "LowPrio", [0, 0, 111]);
const high = addApproved(h.store, "HighPrio", [0, 0, 222]);
addExhibition(h.store, { artworkId: low, startsMs: h.now - 1000, durationMs: 60_000, priority: 1 });
addExhibition(h.store, { artworkId: high, startsMs: h.now - 1000, durationMs: 60_000, priority: 9 });
h.scheduler.tick();
assert.equal(h.engine.currentMeta.title, "HighPrio");
} finally { h.cleanup(); }
});
test("HTTP API: create/conflict(409)/replace-resolution/cancel + auth + public schedule", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pixel8-p3http-"));
const config = loadConfig({ PORT: "0", HOST: "127.0.0.1", DB_PATH: path.join(tmpDir, "t.db"), MEDIA_DIR: path.join(tmpDir, "media"), ADMIN_TOKEN: "secret", TZ: "UTC" });
const store = openStore(config.dbPath);
const engine = new Engine({ fps: 10 });
engine.setSource(solidSource([0, 0, 0]), "test:black");
const queue = new QueueController({ store, engine, defaultItemMs: 1000, fps: 10 });
const scheduler = new Scheduler({ store, engine, queue, timezone: "UTC", fps: 10 });
const art = addApproved(store, "HttpArt");
const { server } = createServer(config, { engine, store, queue, scheduler });
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const base = `http://127.0.0.1:${server.address().port}`;
const H = { authorization: "Bearer secret", "content-type": "application/json" };
try {
// missing token
assert.equal((await fetch(base + "/api/admin/exhibitions")).status, 401);
// create one exhibition
const start = new Date(Date.now() + 60_000).toISOString();
const r1 = await fetch(base + "/api/admin/exhibitions", { method: "POST", headers: H, body: JSON.stringify({ artwork_id: art, starts_at: start, duration: "1h", priority: 5 }) });
assert.equal(r1.status, 201);
const ex1 = await r1.json();
// overlapping one -> 409 conflict
const r2 = await fetch(base + "/api/admin/exhibitions", { method: "POST", headers: H, body: JSON.stringify({ artwork_id: art, starts_at: start, duration_ms: 3_600_000 }) });
assert.equal(r2.status, 409);
const err = await r2.json();
assert.equal(err.code, "conflict");
assert.equal(err.overlapping.length, 1);
// same overlap with resolution=replace -> 201, first canceled
const r3 = await fetch(base + "/api/admin/exhibitions", { method: "POST", headers: H, body: JSON.stringify({ artwork_id: art, starts_at: start, duration: { value: 15, unit: "m" }, resolution: "replace" }) });
assert.equal(r3.status, 201);
assert.equal(store.db.prepare("SELECT status FROM exhibitions WHERE id=?").get(ex1.id).status, "canceled");
// cancel the new one
const ex3 = await r3.json();
const r4 = await fetch(`${base}/api/admin/exhibitions/${ex3.id}`, { method: "DELETE", headers: H });
assert.equal(r4.status, 200);
assert.equal(store.db.prepare("SELECT status FROM exhibitions WHERE id=?").get(ex3.id).status, "canceled");
// public schedule is read-only and omits canceled works
const r5 = await fetch(base + "/api/schedule");
assert.equal(r5.status, 200);
const pub = await r5.json();
assert.ok(Array.isArray(pub.exhibitions));
assert.equal(pub.exhibitions.length, 0, "canceled exhibitions must not appear publicly");
// duration units from seconds through days are accepted
const r6 = await fetch(base + "/api/admin/exhibitions", { method: "POST", headers: H, body: JSON.stringify({ artwork_id: art, starts_at: start, duration: "30d" }) });
assert.equal(r6.status, 201);
const ex6 = await r6.json();
const spanMs = Date.parse(ex6.ends_at_utc) - Date.parse(ex6.starts_at_utc);
assert.equal(spanMs, 2_592_000_000);
} finally {
if (server.closeAllConnections) server.closeAllConnections();
await new Promise((r) => server.close(r));
store.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});