239 lines
13 KiB
JavaScript
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 });
|
|
}
|
|
});
|