dance-channel.test.js 10.1 KB
// Integration tests for dance.html's backend (T11 独立播出状态机, T12
// GET /api/dance/wardrobe, T15 POST /api/dance/pick, T17 点赞/在线人数).
// Same mock-H3-Console approach as dressup-generate.test.js so the full
// submit -> generate -> land-in-schedule path runs offline and fast.
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const http = require("node:http");
const { spawn } = require("node:child_process");

const PORT = 22934 + Math.floor(Math.random() * 1000);
const BASE = `http://localhost:${PORT}`;
const ADMIN_PASSWORD = "test-password-only-for-this-run";
let cookie;
let child;
let cwd;
let mockH3;
let mockH3Port;
let videoPollCounts;

function waitForServer(url, timeoutMs = 10000) {
  const deadline = Date.now() + timeoutMs;
  return (async function poll() {
    while (Date.now() < deadline) {
      try {
        await fetch(url);
        return;
      } catch {
        await new Promise((r) => setTimeout(r, 100));
      }
    }
    throw new Error("server did not come up in time");
  })();
}

function authed(opts = {}) {
  return { ...opts, headers: { "Content-Type": "application/json", Cookie: cookie, ...opts.headers } };
}

// Same shape as dressup-generate.test.js's mock — enough of /v1/assets,
// /v1/videos and /v1/videos/:id for generateDanceVideo to run end to end.
function startMockH3() {
  videoPollCounts = new Map();
  let nextId = 1;
  return new Promise((resolve) => {
    mockH3 = http.createServer((req, res) => {
      req.url = req.url.replace(/^\/+/, "/");
      const chunks = [];
      req.on("data", (c) => chunks.push(c));
      req.on("end", () => {
        if (req.method === "POST" && req.url === "/v1/assets") {
          const id = `asset-${nextId++}`;
          res.writeHead(200, { "Content-Type": "application/json" });
          res.end(JSON.stringify({ id, url: `http://mock-h3/asset/${id}`, kind: "image" }));
          return;
        }
        if (req.method === "POST" && req.url === "/v1/videos") {
          const id = `job-${nextId++}`;
          res.writeHead(200, { "Content-Type": "application/json" });
          res.end(JSON.stringify({ id }));
          return;
        }
        const pollMatch = req.method === "GET" && req.url.match(/^\/v1\/videos\/(.+)$/);
        if (pollMatch) {
          const id = pollMatch[1];
          const seen = (videoPollCounts.get(id) || 0) + 1;
          videoPollCounts.set(id, seen);
          if (seen < 2) {
            res.writeHead(200, { "Content-Type": "application/json" });
            res.end(JSON.stringify({ id, status: "generating" }));
            return;
          }
          res.writeHead(200, { "Content-Type": "application/json" });
          res.end(JSON.stringify({ id, status: "done", partition: "ref2va", req: { width: 480, height: 832, frames: 240 } }));
          return;
        }
        res.writeHead(404, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ error: "not found in mock" }));
      });
    });
    mockH3.listen(0, () => {
      mockH3Port = mockH3.address().port;
      resolve();
    });
  });
}

test.before(async () => {
  await startMockH3();

  cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dance-channel-"));
  for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js"]) {
    fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f));
  }
  fs.mkdirSync(path.join(cwd, "uploads"), { recursive: true });
  fs.writeFileSync(path.join(cwd, "uploads", "person.png"), "fake-png-bytes");
  fs.writeFileSync(path.join(cwd, "uploads", "garment.png"), "fake-png-bytes");
  fs.writeFileSync(path.join(cwd, "uploads", "ref.mp4"), "fake-mp4-bytes");

  child = spawn(process.execPath, ["server.js"], {
    cwd,
    env: {
      ...process.env,
      PORT: String(PORT),
      H3_CONSOLE_BASE: `http://localhost:${mockH3Port}`,
      H3_CONSOLE_TOKEN: "mock-token",
      ADMIN_PASSWORD,
      GENERATION_POLL_INTERVAL_MS: "400",
      SCHEDULE_TICK_MS: "150",
    },
    stdio: "pipe",
  });
  await waitForServer(BASE);

  const loginRes = await fetch(`${BASE}/api/admin/login`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ password: ADMIN_PASSWORD }),
  });
  cookie = (loginRes.headers.get("set-cookie") || "").split(";")[0];
});

test.after(() => {
  child.kill();
  mockH3.close();
  fs.rmSync(cwd, { recursive: true, force: true });
});

async function waitUntil(fn, timeoutMs = 8000, intervalMs = 100) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const v = await fn();
    if (v) return v;
    await new Promise((r) => setTimeout(r, intervalMs));
  }
  throw new Error("condition not met in time");
}

let defaultCharacter, activeGarment, inactiveGarment, danceRef;

test("seed: default character + active/inactive garments + dance-ref", async () => {
  defaultCharacter = await fetch(
    `${BASE}/api/admin/characters`,
    authed({ method: "POST", body: JSON.stringify({ name: "衣柜小美", imageUrl: "/uploads/person.png", appearanceDescriptor: "black hair", isDefault: true }) })
  ).then((r) => r.json());
  assert.equal(defaultCharacter.isDefault, true);

  activeGarment = await fetch(
    `${BASE}/api/admin/garments`,
    authed({ method: "POST", body: JSON.stringify({ name: "上架红裙", imageUrl: "/uploads/garment.png", appearanceDescriptor: "red dress", isActive: true }) })
  ).then((r) => r.json());
  inactiveGarment = await fetch(
    `${BASE}/api/admin/garments`,
    authed({ method: "POST", body: JSON.stringify({ name: "下架旗袍", imageUrl: "/uploads/garment.png", appearanceDescriptor: "qipao", isActive: false }) })
  ).then((r) => r.json());

  danceRef = await fetch(
    `${BASE}/api/admin/dance-refs`,
    authed({ method: "POST", body: JSON.stringify({ name: "衣柜拉丁舞", videoUrl: "/uploads/ref.mp4", motionDescriptor: "hip sways" }) })
  ).then((r) => r.json());
  assert.ok(danceRef.id);
});

test("T12 GET /api/dance/wardrobe: only active garments, no admin auth needed", async () => {
  const res = await fetch(`${BASE}/api/dance/wardrobe`); // deliberately no cookie
  assert.equal(res.status, 200);
  const list = await res.json();
  assert.ok(list.some((g) => g.id === activeGarment.id));
  assert.ok(!list.some((g) => g.id === inactiveGarment.id));
});

test("T15 POST /api/dance/pick: rejects unknown/inactive garmentId", async () => {
  const missing = await fetch(`${BASE}/api/dance/pick`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ garmentId: "nope" }) });
  assert.equal(missing.status, 400);

  const inactive = await fetch(`${BASE}/api/dance/pick`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ garmentId: inactiveGarment.id }),
  });
  assert.equal(inactive.status, 400);
});

test("T15/T11 pick a garment (no login/cookie needed) -> generates -> lands in dance schedule; T17 vote + viewer count along the way", async () => {
  const pickRes = await fetch(`${BASE}/api/dance/pick`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ garmentId: activeGarment.id }),
  });
  assert.equal(pickRes.status, 200);
  const picked = await pickRes.json();
  assert.equal(picked.status, "generating");
  assert.ok(picked.id);

  // While it's still generating, it should show up in the public queue and
  // accept a vote (T17) — separate viewer/vote namespace from live.html.
  const stateWhileGenerating = await fetch(`${BASE}/api/dance/live/state?cid=test-cid-1`).then((r) => r.json());
  assert.ok(stateWhileGenerating.queue.some((q) => q.id === picked.id && q.status === "generating"));
  assert.equal(stateWhileGenerating.watching, 1);

  const voteRes = await fetch(`${BASE}/api/dance/queue/vote/${picked.id}`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ uid: "viewer-1" }),
  });
  assert.equal(voteRes.status, 200);
  const vote = await voteRes.json();
  assert.equal(vote.voted, true);
  assert.equal(vote.votes, 1);

  // Eventually the mock generation finishes and ensureDanceSchedule sweeps
  // it into the schedule — current or upNext should carry the right
  // character/garment traceability, and the entry should drop out of queue.
  const finalState = await waitUntil(async () => {
    const state = await fetch(`${BASE}/api/dance/live/state?cid=test-cid-1`).then((r) => r.json());
    const inQueue = state.queue.some((q) => q.id === picked.id);
    const landed = (state.current && state.current.clip && state.current.clip.garmentId === activeGarment.id) || state.upNext.some((c) => c.garmentId === activeGarment.id);
    return !inQueue && landed ? state : null;
  });
  const landedClip = finalState.current && finalState.current.clip && finalState.current.clip.garmentId === activeGarment.id ? finalState.current.clip : finalState.upNext.find((c) => c.garmentId === activeGarment.id);
  assert.equal(landedClip.characterId, defaultCharacter.id);
  assert.equal(landedClip.refDanceVideoId, danceRef.id);
  assert.equal(landedClip.channel, "dance");
});

test("T15 dedupe: re-picking the same garment returns ready immediately (M.7 cache hit), no new H3 submission", async () => {
  const before = videoPollCounts.size;
  const res = await fetch(`${BASE}/api/dance/pick`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ garmentId: activeGarment.id }),
  });
  assert.equal(res.status, 200);
  const data = await res.json();
  assert.equal(data.status, "ready");
  assert.ok(data.videoId);
  assert.equal(videoPollCounts.size, before); // no new /v1/videos/:id job started
});

test("T17: live.html and dance.html viewer counts are independent", async () => {
  await fetch(`${BASE}/api/live/state?cid=live-viewer-a`);
  await fetch(`${BASE}/api/live/state?cid=live-viewer-b`);
  const danceState = await fetch(`${BASE}/api/dance/live/state?cid=dance-viewer-only`).then((r) => r.json());
  assert.equal(danceState.watching, 2); // test-cid-1 (still within TTL) + dance-viewer-only, not the two live viewers
});