Files
Claude 3adebb731f Rename FunPack to FunUI (5.0.3 Classy Chopper): /fun endpoint, wink logo, load-time migration of old names
Old project/workflow names and browser settings migrate on load; /funpack redirects to /fun.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016acRss69eH1235eRdFTZHY
2026-10-25 13:11:03 +00:00

546 lines
26 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
import { addTags, bulk, frequency, joinTags, splitTags } from "./captions.js";
let feature;
test.before(async () => { setupDom(); feature = (await import("./index.js")).default; });
test.after(() => teardownDom());
const wait = (ms = 20) => new Promise((r) => setTimeout(r, ms));
function fake({ enabled = true } = {}) {
const calls = [];
const doc = { name: "cats", trigger: "", settings: {}, items: [
{ id: "aaaaaaaaaaaa", src: { kind: "bin", id: "bbbbbbbbbbbb" }, media_kind: "image", label: "own", caption: "cat, grey", name: "a.png" },
{ id: "cccccccccccc", src: { kind: "output", type: "output", subfolder: "", filename: "v.mp4" }, media_kind: "video", label: "liked", caption: "", name: "v.mp4" },
] };
const run = { run: "", state: "idle", loss: [], loss_from: 0, step: 0, total: 0 };
const api = {
trainState: async () => ({ enabled }),
trainSets: async () => ({ sets: [{ name: "cats", count: 2 }] }),
trainSet: async () => structuredClone(doc),
trainSaveSet: async (name, d) => { calls.push(["save", name, d]); return d; },
trainClips: async () => ({ clips: { "output:/v.mp4": { frames: 124, fps: 24 } } }),
trainAdd: async () => ({ set: doc, added: 0 }),
trainRated: async () => ({ results: [], counts: { liked: 3 } }),
trainTaggers: async () => ({ taggers: [{ id: "wd_vit_v3", title: "WD ViT v3", state: "absent", hint: "", done: 0, total: 0 }] }),
trainAutotagState: async () => ({ state: "idle" }),
trainRun: async () => ({ ...run }),
trainRuns: async () => [],
trainPlan: async (set, slots, settings) => { calls.push(["plan", set, settings]); return { prompt: { 1: { class_type: "FunUITrainLora", inputs: {} } }, problems: [] }; },
queueGraph: async (g) => { calls.push(["queue", g]); return "pid"; },
trainStop: async () => { calls.push(["stop"]); return { stopped: true }; },
media: async () => ({ media: [{ id: "bbbbbbbbbbbb", name: "a.png", kind: "image" }, { id: "dddddddddddd", name: "s.wav", kind: "audio" }] }),
uploadMedia: async () => ({ media: [], problems: [] }),
};
const app = { api, dom: {}, pipeline: { slots: () => [{ id: "model" }] }, say: () => {}, on: () => () => {} };
return { api, app, calls, doc, run };
}
const open = async (f) => {
const host = document.createElement("div");
document.body.append(host);
const off = feature.setup({ host, app: f.app });
await wait();
const button = [...host.querySelectorAll("button")].find((b) => b.textContent.includes("Training"));
return { host, button, off };
};
const text = () => document.body.textContent;
const tab = (label) => click(label === "Results" ? "See results" : label === "Train" ? "Back to training" : label);
const click = (label, root = document) => [...root.querySelectorAll("button")].find((b) => b.textContent.trim() === label);
test("captions: tags are trimmed and unique, counts are per caption, bulk edits only the chosen", () => {
assert.deepEqual(splitTags(" a,B ,, a\nc "), ["a", "B", "c"]);
assert.equal(joinTags(["x", "X", " y "]), "x, y");
assert.deepEqual(frequency([{ caption: "a, b" }, { caption: "a, a" }, { caption: "c" }]).map((x) => [x.tag, x.count]), [["a", 2], ["b", 1], ["c", 1]]);
const items = [{ id: "x", caption: "cat, dog" }, { id: "y", caption: "cat" }];
assert.equal(bulk(items, "remove", "cat", "", new Set(["x"])), 1);
assert.deepEqual(items.map((i) => i.caption), ["dog", "cat"]);
assert.equal(bulk(items, "replace", "cat", "lion"), 1);
assert.equal(bulk(items, "prepend", "zq"), 2);
assert.equal(items[1].caption, "zq, lion");
assert.equal(bulk(items, "add", ""), 0);
const prose = [{ id: "p", caption: "A fox runs.\nIt jumps, twice, home" }];
assert.equal(bulk(prose, "add", "sky"), 1);
assert.equal(prose[0].caption, "A fox runs.\nIt jumps, twice, home, sky", "only commas are touched: the words stay as written");
assert.equal(addTags("a fox, running home.", ["A Fox", "grass", "sky"]), "a fox, running home., grass, sky");
assert.equal(addTags("", ["x", "y"]), "x, y");
assert.equal(addTags("a long prompt,", ["x"]), "a long prompt, x");
});
test("the window is hidden while the add-on is off", async () => {
const f = fake({ enabled: false });
const { button, off } = await open(f);
assert.ok(button.hidden);
off();
});
test("the Data step lists the sets, offers the three sources and can be skipped", async () => {
const f = fake();
const { button, off } = await open(f);
assert.ok(!button.hidden);
button.click();
await wait(60);
const t = text();
for (const word of ["Drop pictures or clips here", "Choose in your Bin", "Liked results", "Bad composition results", "Bad image results", "Skip to training", "Next: tags"]) assert.ok(t.includes(word), word);
assert.ok(t.includes("2 in \"cats\""), "the open set is summarised");
assert.ok(document.querySelectorAll(".cx-cell").length === 1, "only pictures and clips are offered from the Bin, not sound");
off();
});
test("Tags step: a text box per item under its picture; typing saves the set and keeps the text as typed", async () => {
const f = fake();
const { button, off } = await open(f);
button.click();
await wait(60);
click("Next: tags").click();
await wait(20);
assert.equal(document.querySelectorAll(".cx-captioned").length, 2);
const box = document.querySelector(".cx-captioned textarea");
assert.equal(box.value, "cat, grey");
box.value = "A cat\nsits, calmly";
box.dispatchEvent(new window.Event("input", { bubbles: true }));
await wait(700);
const saved = f.calls.filter((c) => c[0] === "save").pop();
assert.equal(saved[2].items[0].caption, "A cat\nsits, calmly");
assert.ok(box.isConnected && document.querySelector(".cx-captioned textarea") === box, "saving does not redraw the step: the box being typed in stays");
off();
});
test("Train step: Start sends the settings and the pipeline to the plan and queues the graph", async () => {
const f = fake();
const { button, off } = await open(f);
button.click();
await wait(60);
click("Skip to training").click();
await wait(20);
assert.ok(text().includes("Start training"));
click("Start training").click();
await wait(40);
const plan = f.calls.find((c) => c[0] === "plan");
assert.equal(plan[1], "cats");
assert.equal(plan[2].steps, 500);
assert.ok(f.calls.some((c) => c[0] === "queue"));
off();
});
test("Train step: More settings stays open while an option in it is changed", async () => {
const f = fake();
const { button, off } = await open(f);
button.click();
await wait(60);
click("Skip to training").click();
await wait(20);
const more = () => document.querySelector("details.cx-collapsible");
more().open = true;
more().dispatchEvent(new Event("toggle"));
const select = [...more().querySelectorAll("select")][0];
select.value = "LoHa";
select.dispatchEvent(new Event("change", { bubbles: true }));
await wait(40);
assert.equal(more().open, true);
off();
});
test("Tags step: the prefix and postfix are the set's own, saved with it and sent to the run", async () => {
const f = fake();
const { button, off } = await open(f);
button.click();
await wait(60);
click("Next: tags").click();
await wait(20);
const field = (label) => [...document.querySelectorAll(".cx-field")].find((x) => x.textContent.startsWith(label)).querySelector("input");
for (const [label, text] of [["Prefix", "zq " + "a long prefix of many words, ".repeat(12) + "[Shot 1]"], ["Postfix", "end"]]) {
const input = field(label);
input.value = text;
input.dispatchEvent(new window.Event("change", { bubbles: true }));
}
await wait(700);
const saved = f.calls.filter((c) => c[0] === "save").pop();
assert.equal(saved[2].trigger, "zq " + "a long prefix of many words, ".repeat(12) + "[Shot 1]", "a long prefix is kept whole");
assert.equal(saved[2].postfix, "end");
click("Next: train").click();
await wait(20);
click("Start training").click();
await wait(40);
const plan = f.calls.find((c) => c[0] === "plan");
assert.equal(plan[2].trigger.slice(0, 2), "zq");
assert.ok(plan[2].trigger.endsWith("[Shot 1]"));
assert.equal(plan[2].postfix, "end");
off();
});
test("a refused plan shows the reason and starts nothing", async () => {
const f = fake();
f.api.trainPlan = async () => ({ prompt: {}, problems: ["the pipeline has no clip loader"] });
const { button, off } = await open(f);
button.click();
await wait(60);
click("Skip to training").click();
await wait(20);
click("Start training").click();
await wait(40);
assert.ok(text().includes("no clip loader"));
assert.ok(!f.calls.some((c) => c[0] === "queue"));
off();
});
test("a run in progress draws its loss, then reports the saved file; Stop asks the server", async () => {
const f = fake();
let n = 0;
f.api.trainRun = async (since) => {
n += 1;
if (n < 3) return { run: "", state: "idle", loss: [], loss_from: 0 };
if (n === 3) return { run: "r1", state: "training", step: 2, total: 5, elapsed: 4, message: "", loss: [0.9, 0.7], loss_from: since };
return { run: "r1", state: "done", step: 5, total: 5, elapsed: 9, message: "", loss: [0.5, 0.4, 0.3].slice(0, 5 - since), loss_from: since, lora: "cats_lora.safetensors" };
};
const { button, off } = await open(f);
button.click();
await wait(60);
click("Skip to training").click();
await wait(20);
click("Start training").click();
await wait(1200);
assert.ok(text().includes("Graphs and previews are on the Results step"), "Train only says where to look");
assert.ok(!document.querySelector(".cx-chart"), "the graph is not on the Train tab");
tab("Results").click();
await wait(20);
assert.ok(text().includes("Step 2 of 5"), "progress is shown");
assert.ok(document.querySelector(".cx-chart path.cx-chart-line"), "the loss is drawn");
tab("Train").click();
await wait(20);
click("Stop").click();
await wait(20);
assert.ok(f.calls.some((c) => c[0] === "stop"));
tab("Results").click();
await wait(1200);
assert.ok(text().includes("cats_lora.safetensors"));
off();
});
test("rated results are added with the captions the server built, and changing what to include asks again", async () => {
const f = fake();
const asked = [];
f.api.trainRated = async (include) => { asked.push(include); return { results: [{ label: "liked", media: { type: "output", subfolder: "", filename: "x.mp4" }, caption: include.includes("postfix") ? "a red fox 35mm" : "a red fox" }], counts: { liked: 1 } }; };
const added = [];
f.api.trainAdd = async (name, entries) => { added.push(entries); return { set: f.doc, added: entries.length }; };
const { button, off } = await open(f);
button.click();
await wait(60);
assert.deepEqual(asked[0], ["main"], "the main prompt alone is the default");
click("Add these results").click();
await wait(40);
assert.equal(added[0][0].caption, "a red fox");
assert.equal(added[0][0].media_kind, "video");
const box = [...document.querySelectorAll(".cx-checklist .cx-check-row")].find((r) => r.textContent.includes("Postfix")).querySelector("input");
box.click();
await wait(40);
assert.deepEqual(asked[asked.length - 1].sort(), ["main", "postfix"]);
click("Add these results").click();
await wait(40);
assert.equal(added[1][0].caption, "a red fox 35mm");
off();
});
test("Tags step: Remove selected takes the ticked items out of the set and saves", async () => {
const f = fake();
const { button, off } = await open(f);
button.click();
await wait(60);
click("Next: tags").click();
await wait(20);
document.querySelector(".cx-captioned-pick").click();
click("Remove selected").click();
await wait(40);
const yes = [...document.querySelectorAll("button")].filter((b) => b.textContent.trim() === "Remove").pop();
yes.click();
await wait(700);
const saved = f.calls.filter((c) => c[0] === "save").pop();
assert.equal(saved[2].items.length, 1);
assert.equal(saved[2].items[0].id, "cccccccccccc");
off();
});
test("rated results are shown as pictures, and only the picked ones are added", async () => {
const f = fake();
f.api.trainRated = async () => ({ results: [
{ label: "liked", media: { type: "output", subfolder: "", filename: "x.mp4" }, caption: "a" },
{ label: "liked", media: { type: "output", subfolder: "", filename: "y.png" }, caption: "b" }], counts: { liked: 2 } });
const added = [];
f.api.trainAdd = async (name, entries) => { added.push(entries); return { set: f.doc, added: entries.length }; };
const { button, off } = await open(f);
button.click();
await wait(60);
assert.ok(text().includes("2 rated results found"));
const cells = [...document.querySelectorAll(".cx-cell")];
assert.equal(cells.length, 3, "one bin item and the two rated results are drawn as pictures");
(cells[2].querySelector("button") || cells[2]).click();
await wait(20);
assert.ok(text().includes("1 picked: only those are added"));
click("Add these results").click();
await wait(40);
assert.equal(added[0].length, 1);
assert.equal(added[0][0].src.filename, "y.png");
off();
});
test("Tags step: a clip's frame count and rate show beside its label", async () => {
const f = fake();
const { button, off } = await open(f);
button.click();
await wait(60);
click("Next: tags").click();
await wait(100);
assert.ok(text().includes("124 f · 24 fps"));
off();
});
test("a preview step pauses the run, the pipeline makes a preview with the LoRA so far, and training carries on", async () => {
const f = fake();
f.doc.settings = { preview_strength: -0.8 };
const previews = [];
f.app.runner = { preview: async (o) => { previews.push(o); return { image: { filename: "pv.mp4", subfolder: "", type: "output" } }; } };
let queued = 0, polls = 0;
f.api.queueGraph = async (g) => { queued += 1; f.calls.push(["queue", g]); return "pid"; };
f.api.trainRun = async () => {
polls += 1;
if (queued === 0) return { run: "", state: "idle", loss: [], loss_from: 0 };
if (queued === 1) return polls % 2 ? { run: "r1", state: "training", step: 2, total: 6, elapsed: 3, message: "", loss: [0.9, 0.8], loss_from: 0 }
: { run: "r1", state: "paused", step: 2, total: 6, elapsed: 3, message: "", loss: [], loss_from: 2, paused: { reason: "preview", step: 2, preview: "cats_preview.safetensors", checkpoint: true } };
if (polls % 3 === 0) return { run: "r1", state: "paused", step: 2, total: 6, message: "", loss: [], loss_from: 2, paused: { reason: "preview", step: 2, preview: "cats_preview.safetensors", checkpoint: true } }; // the record still shows the pause until the new run begins
return { run: "r1", state: "done", step: 6, total: 6, elapsed: 9, message: "", loss: [0.7, 0.6, 0.5, 0.4], loss_from: 2, lora: "cats_lora.safetensors" };
};
const { button, off } = await open(f);
button.click();
await wait(60);
click("Skip to training").click();
await wait(20);
click("Start training").click();
tab("Results").click();
await wait(3600);
assert.equal(previews.length, 1);
assert.equal(previews[0].lora, "cats_preview.safetensors");
assert.equal(previews[0].strength, -0.8, "the preview uses the strength set for it, here a negative one");
assert.ok(!("preview_strength" in f.calls.find((c) => c[0] === "plan")[2]), "and it is not sent to the trainer");
assert.equal(queued, 2, "training was queued again to carry on");
const plans = f.calls.filter((c) => c[0] === "plan");
assert.equal(plans[1][2].resume, "r1");
assert.ok(text().includes("Step 2"), "the preview is shown with its step");
await wait(3000);
assert.ok(text().includes("cats_lora.safetensors"));
off();
});
test("a pause that the window never saw training towards is still shown at the step the server is at", async () => {
const f = fake();
f.app.runner = { preview: async () => ({ image: { filename: "pv.png", subfolder: "", type: "output" } }) };
let queued = 0;
f.api.queueGraph = async () => { queued += 1; return "pid"; };
const pause = (step, started) => ({ run: "r1", state: "paused", step, total: 6, started, elapsed: 3, message: "", loss: [0.9, 0.8], loss_from: 0, paused: { reason: "user", step, preview: "p.safetensors", checkpoint: true } });
f.api.trainRun = async () => (queued === 0 ? { run: "", state: "idle", loss: [], loss_from: 0 } : queued === 1 ? pause(2, 100) : pause(4, 200));
const { button, off } = await open(f);
button.click();
await wait(60);
click("Skip to training").click();
await wait(20);
click("Start training").click();
await wait(1100);
assert.ok(text().includes("paused at step 2"));
click("Resume").click();
await wait(1400);
assert.ok(text().includes("paused at step 4"), "the second pause is not mistaken for the old one");
off();
});
test("Pause asks the server, a paused run offers Resume, Preview now and Stop, and Stop ends it", async () => {
const f = fake();
f.app.runner = { preview: async () => ({ error: "x" }) };
let phase = 0;
f.api.trainPause = async () => { f.calls.push(["pause"]); phase = 2; return { paused: true }; };
f.api.queueGraph = async () => { phase = 1; return "pid"; };
f.api.trainRun = async () => phase === 0 ? { run: "", state: "idle", loss: [], loss_from: 0 }
: phase === 1 ? { run: "r2", state: "training", step: 1, total: 6, elapsed: 1, message: "", loss: [0.9], loss_from: 0 }
: { run: "r2", state: "paused", step: 2, total: 6, message: "", loss: [0.8], loss_from: 1, paused: { reason: "user", step: 2, preview: "cats_preview.safetensors", checkpoint: true } };
const { button, off } = await open(f);
button.click();
await wait(60);
click("Skip to training").click();
await wait(20);
click("Start training").click();
await wait(1400);
click("Pause").click();
await wait(20);
assert.ok(text().includes("Pausing at the end of this step"), "the controls' message is beside the controls");
tab("Results").click();
await wait(20);
assert.ok(!text().includes("Pausing at the end of this step"), "and not with the graph");
tab("Train").click();
await wait(1400);
assert.ok(f.calls.some((c) => c[0] === "pause"));
assert.ok(text().includes("paused at step 2"));
assert.ok(click("Resume") && !click("Resume").hidden && !click("Preview now").hidden);
click("Stop").click();
await wait(40);
assert.ok(f.calls.some((c) => c[0] === "stop"));
assert.ok(text().includes("Training stopped."));
off();
});
test("Restore and continue is offered for a failed run, goes away while it carries on, and comes back if it fails again", async () => {
const f = fake();
let failed = true, phase = 0;
const rec = () => ({ run: "r9", state: "error", steps: 51, total: 6, set: "cats", loss: [0.5], error: "boom", resumable: failed, settings: {} });
f.api.trainRuns = async () => (phase === 1 ? [] : [{ run: "r9", name: "cats", set: "cats", state: "error", steps: 51, total: 6 }]);
f.api.trainRunRecord = async () => rec();
f.api.queueGraph = async () => { phase = 1; return "pid"; };
f.api.trainRun = async () => phase === 0 ? { run: "r9", started: 5, state: "error", step: 51, total: 6, loss: [], loss_from: 0 }
: phase === 1 ? { run: "r9", started: 9, state: "training", step: 52, total: 6, elapsed: 1, loss: [], loss_from: 0 }
: { run: "r9", started: 9, state: "error", step: 53, total: 6, error: "again", loss: [], loss_from: 0 };
const { button, off } = await open(f);
button.click();
await wait(60);
tab("Results").click();
await wait(60);
assert.ok(click("Restore and continue") && !click("Restore and continue").hidden);
click("Restore and continue").click();
await wait(1300);
assert.equal(f.calls.filter((c) => c[0] === "plan").pop()[2].resume, "r9");
assert.ok(!click("Restore and continue") || click("Restore and continue").hidden, "hidden while it carries on");
phase = 2;
await wait(1500);
assert.ok(click("Restore and continue") && !click("Restore and continue").hidden, "offered again after the next failure");
off();
});
test("Train step: the preset picked stays picked when a setting is changed after it, and is not sent to the trainer", async () => {
const f = fake();
const { button, off } = await open(f);
button.click();
await wait(60);
click("Skip to training").click();
await wait(20);
click("Thorough").click();
await wait(20);
const rank = [...document.querySelectorAll(".cx-field")].find((x) => x.textContent.startsWith("Rank")).querySelector("input");
rank.value = "8";
rank.dispatchEvent(new window.Event("change", { bubbles: true }));
await wait(700);
const picked = [...document.querySelectorAll("[aria-pressed=true], [aria-checked=true], .is-active, .on")].map((e) => e.textContent.trim());
assert.ok(picked.includes("Thorough") && !picked.includes("Quick check"), `picked: ${picked}`);
click("Start training").click();
await wait(60);
const plan = f.calls.find((c) => c[0] === "plan");
assert.equal(plan[2].rank, 8);
assert.equal(plan[2].steps, 2000);
assert.ok(!("preset" in plan[2]));
off();
});
test("Results: a finished run has a Download LoRA button that saves the file under its own name", async () => {
const f = fake();
const saved = [];
f.app.dom.saveBlob = (blob, name) => saved.push(name);
f.api.trainLoraUrl = (id) => `/lora/${id}`;
f.api.trainRuns = async () => [{ run: "r5", name: "cats", set: "cats", state: "done", steps: 10, total: 10, lora: "cats_lora.safetensors" }];
f.api.trainRunRecord = async () => ({ run: "r5", state: "done", steps: 10, total: 10, set: "cats", loss: [0.2], lora: "cats_lora.safetensors", settings: {} });
const realFetch = globalThis.fetch;
globalThis.fetch = async (url) => (String(url) === "/lora/r5" ? { ok: true, blob: async () => new Blob(["w"]) } : realFetch(url));
try {
const { button, off } = await open(f);
button.click();
await wait(60);
tab("Results").click();
await wait(80);
assert.ok(click("Download LoRA"));
click("Download LoRA").click();
await wait(60);
assert.deepEqual(saved, ["cats_lora.safetensors"]);
off();
} finally { globalThis.fetch = realFetch; }
});
test("Results: steps and the preview can be picked and downloaded, not only the final file", async () => {
const f = fake();
const urls = [], saved = [];
f.app.dom.saveBlob = (blob, name) => saved.push(name);
f.api.trainLoraUrl = (id, file) => `/lora/${id}?file=${file || ""}`;
f.api.trainRuns = async () => [{ run: "r6", name: "cats", set: "cats", state: "stopped", steps: 120, total: 500 }];
f.api.trainRunRecord = async () => ({ run: "r6", state: "stopped", steps: 120, total: 500, set: "cats", loss: [0.2], lora: "", files: ["cats_step50.safetensors", "cats_step100.safetensors", "cats_preview.safetensors"], settings: {} });
const realFetch = globalThis.fetch;
globalThis.fetch = async (url) => { urls.push(String(url)); return String(url).startsWith("/lora/") ? { ok: true, blob: async () => new Blob(["w"]) } : realFetch(url); };
try {
const { button, off } = await open(f);
button.click();
await wait(60);
tab("Results").click();
await wait(80);
const pick = [...document.querySelectorAll("select")].find((s) => [...s.options].some((o) => o.textContent === "Step 100"));
assert.ok(pick, "a choice of files");
pick.value = "cats_step100.safetensors";
pick.dispatchEvent(new window.Event("change", { bubbles: true }));
await wait(20);
click("Download LoRA").click();
await wait(60);
assert.deepEqual(saved, ["cats_step100.safetensors"]);
assert.ok(urls.includes("/lora/r6?file=cats_step100.safetensors"));
off();
} finally { globalThis.fetch = realFetch; }
});
test("Results: a run shows the time per step and the time spent in all, restarts included", async () => {
const f = fake();
let n = 0;
f.api.trainRun = async (since) => {
n += 1;
if (n < 3) return { run: "", state: "idle", loss: [], loss_from: 0 };
return { run: "r7", state: "training", step: 4, total: 100, elapsed: 125, message: "", loss: [0.9, 0.8, 0.7, 0.6].slice(since), loss_from: since, times: [null, 3.5, 3.3, 3.4].slice(since), step_seconds: 3.4, last_step_seconds: 3.4 };
};
const { button, off } = await open(f);
button.click();
await wait(60);
click("Skip to training").click();
await wait(20);
click("Start training").click();
await wait(1800);
tab("Results").click();
await wait(40);
const t = text();
assert.ok(t.includes("3.4 s per step on average"), "the per-step time");
assert.ok(t.includes("2 min 5 s spent in all, the restarts included"), "the total");
assert.ok(t.includes("about 5 min 26 s left"), "the estimate comes from the step time");
assert.equal(document.querySelectorAll(".cx-chart").length, 2, "a graph of the step times beside the loss");
off();
});
test("Data step: results whose rating may be from another video are marked, not hidden", async () => {
const f = fake();
f.api.trainRated = async () => ({ results: [{ label: "liked", media: { type: "output", subfolder: "", filename: "x.mp4" }, caption: "a red fox", doubtful: true }, { label: "liked", media: { type: "output", subfolder: "", filename: "y.mp4" }, caption: "a cat" }], counts: { liked: 2 }, doubtful: 1 });
const { button, off } = await open(f);
button.click();
await wait(60);
const rated = [...document.querySelectorAll("button")].find((b) => /rated|results/i.test(b.textContent) && /Add|Use|Open/i.test(b.textContent));
if (rated) rated.click();
await wait(60);
assert.ok(text().includes('1 marked "old rating?"'), "the count is shown, and nothing is hidden");
off();
});
test("Steps: no tabs; with no media the way on to training is closed, and Results leads back to the set and its tags", async () => {
const f = fake();
f.doc.items = [];
const { button, off } = await open(f);
button.click();
await wait(60);
assert.ok(!document.querySelector("[role=tab], [role=tablist]"), "no tab strip");
assert.ok(text().includes("1 / 4"), "the step says where it is");
assert.ok(click("Next: tags").disabled && click("Skip to training").disabled, "no media, no training");
click("See results").click();
await wait(40);
assert.ok(click("Prepare a set") && click("Fix tags"), "back to the set or its tags while a run goes on");
click("Fix tags").click();
await wait(40);
assert.ok(text().includes("1 / 4"), "an empty set sends you to add media first");
off();
});