196 lines
11 KiB
TypeScript
196 lines
11 KiB
TypeScript
// разовый генератор golden-эталонов доски: исполняет <script> страницы links.html ревизии 3931a88 в node:vm с заглушкой
|
||
// DOM и записывает результаты её функций на фикстурах в src/board/__fixtures__/*.expected.json.
|
||
// Запуск: cd web && npm run golden (Node 24 исполняет .ts без сборки). Эталоны коммитятся; скрипт перезапускать только
|
||
// при смене фикстур.
|
||
import { execFileSync } from "node:child_process";
|
||
import { readFileSync, writeFileSync } from "node:fs";
|
||
import vm from "node:vm";
|
||
import { canon } from "../src/test/canon.ts";
|
||
|
||
const REV = "3931a88";
|
||
const NOW = "2026-10-14T12:00:00.000Z";
|
||
const FIX = new URL("../src/board/__fixtures__/", import.meta.url);
|
||
const read = (name: string) => readFileSync(new URL(name, FIX), "utf8");
|
||
const write = (name: string, v: unknown) => writeFileSync(new URL(name, FIX), JSON.stringify(canon(v), null, 1) + "\n");
|
||
|
||
const html = execFileSync("git", ["show", `${REV}:src/jira_hub/web/links.html`], { encoding: "utf8" });
|
||
const page = html.slice(html.indexOf("<script>") + "<script>".length, html.lastIndexOf("</script>"));
|
||
|
||
// элемент-заглушка: хранит стили, атрибуты и детей; querySelector возвращает один и тот же элемент на селектор;
|
||
// высота карточки 117 px, её заголовка (.title) 38 px, остальных элементов 0
|
||
class El {
|
||
className = ""; hidden = false; disabled = false; title = ""; value = ""; textContent = ""; open = false; scrollLeft = 0;
|
||
style: Record<string, unknown> = {
|
||
setProperty(k: string, v: string) { this[k] = v; },
|
||
removeProperty(k: string) { delete this[k]; },
|
||
};
|
||
dataset: Record<string, string> = {};
|
||
attrs: Record<string, unknown> = {};
|
||
children: El[] = [];
|
||
sel = "";
|
||
private html = "";
|
||
private q = new Map<string, El>();
|
||
classList = { add() {}, remove() {}, toggle() {}, contains: () => false };
|
||
get innerHTML() { return this.html; }
|
||
set innerHTML(v: string) { this.html = v; this.children = []; }
|
||
get offsetHeight() { return this.sel === ".title" ? 38 : this.className.startsWith("card") ? 117 : 0; }
|
||
get offsetWidth() { return 0; }
|
||
get scrollWidth() { return 0; }
|
||
get clientWidth() { return 0; }
|
||
appendChild(c: El) { this.children.push(c); return c; }
|
||
querySelector(s: string) { let e = this.q.get(s); if (!e) { e = new El(); e.sel = s; this.q.set(s, e); } return e; }
|
||
// две заглушки: страница деструктурирует кнопки «Выше»/«Ниже» у дорожки
|
||
querySelectorAll() { return [new El(), new El()]; }
|
||
setAttribute(k: string, v: unknown) { this.attrs[k] = v; }
|
||
getAttribute(k: string) { return this.attrs[k] ?? null; }
|
||
removeAttribute(k: string) { delete this.attrs[k]; }
|
||
addEventListener() {} removeEventListener() {} setPointerCapture() {} focus() {} select() {} click() {} remove() {}
|
||
contains() { return false; }
|
||
closest() { return null; }
|
||
getBoundingClientRect() { return { top: 0, left: 0, width: 0, height: 0 }; }
|
||
}
|
||
|
||
const byId = new Map<string, El>(), bySel = new Map<string, El>();
|
||
const document = {
|
||
getElementById: (id: string) => { let e = byId.get(id); if (!e) { e = new El(); byId.set(id, e); } return e; },
|
||
querySelector: (s: string) => { let e = bySel.get(s); if (!e) { e = new El(); bySel.set(s, e); } return e; },
|
||
querySelectorAll: () => [],
|
||
createElement: () => new El(),
|
||
createElementNS: () => new El(),
|
||
addEventListener() {},
|
||
documentElement: new El(),
|
||
body: new El(),
|
||
};
|
||
const data = new Map<string, string>();
|
||
const localStorage = {
|
||
getItem: (k: string) => (data.has(k) ? data.get(k)! : null),
|
||
setItem: (k: string, v: unknown) => { data.set(k, String(v)); },
|
||
removeItem: (k: string) => { data.delete(k); },
|
||
clear: () => data.clear(),
|
||
key: (i: number) => [...data.keys()][i] ?? null,
|
||
get length() { return data.size; },
|
||
};
|
||
const sandbox: Record<string, unknown> = {
|
||
document, localStorage, console, setTimeout, clearTimeout, URL, Blob, navigator: {},
|
||
requestAnimationFrame: (f: () => void) => setTimeout(f, 0),
|
||
getComputedStyle: () => ({ position: "static" }),
|
||
fetch: () => Promise.reject(new Error("нет сети")),
|
||
addEventListener() {}, removeEventListener() {},
|
||
};
|
||
const ctx = vm.createContext(sandbox);
|
||
sandbox.window = vm.runInContext("globalThis", ctx);
|
||
// фиксированное «сегодня»: findProblems, колонки дат и exportedAt зависят от new Date()
|
||
vm.runInContext(`{ const R = Date; Date = class extends R { constructor(...a) { super(...(a.length ? a : ["${NOW}"])); }
|
||
static now() { return R.parse("${NOW}"); } }; }`, ctx);
|
||
vm.runInContext(page, ctx, { filename: "links.html@" + REV });
|
||
|
||
// доступ к функциям и состоянию страницы (let/const скрипта видны следующим скриптам того же контекста)
|
||
vm.runInContext(String.raw`
|
||
function dump() { const o = {}; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); o[k] = localStorage.getItem(k); } return o; }
|
||
function reset() {
|
||
localStorage.clear();
|
||
Object.assign(S, { neighbors: false, onlyLinked: false, onlyHand: false, crit: false, byDate: false, snap: true,
|
||
sprints: new Set(), who: new Set(), hideKind: new Set(), hideEdge: new Set() });
|
||
}
|
||
globalThis.__g = {
|
||
dicts: () => ({ kindLabels: KIND_LABELS, linkTypes: LINK_TYPES }),
|
||
fromHub: (s) => fromHub(s),
|
||
fromCsv: (t, n) => fromJiraCsv(t, n),
|
||
parseCsv: (t) => parseCsv(t),
|
||
fromJson: (t, n) => fromJson(t, n),
|
||
facets(model, remembered) {
|
||
reset(); MODEL = model;
|
||
if (remembered !== undefined) localStorage.setItem(fileKey("sprint"), JSON.stringify(remembered));
|
||
fillSprints(); fillWho();
|
||
return { sprintItems: sprintItems().items, whoItems: whoItems().items, sprints: S.sprints, who: S.who };
|
||
},
|
||
scenario(model, sc) {
|
||
reset(); MODEL = model;
|
||
const f = sc.filters || {};
|
||
for (const k of ["neighbors", "onlyLinked", "onlyHand", "crit", "byDate"]) S[k] = !!f[k];
|
||
for (const k of ["sprints", "who", "hideKind", "hideEdge"]) S[k] = new Set(f[k] || []);
|
||
if (sc.laneOrder) localStorage.setItem(fileKey("lanes"), JSON.stringify(sc.laneOrder));
|
||
if (sc.folded) localStorage.setItem(fileKey("fold"), JSON.stringify(sc.folded));
|
||
if (sc.saved) localStorage.setItem(fileKey("pos"), JSON.stringify(sc.saved));
|
||
if (sc.savedDate) localStorage.setItem(fileKey("posdate"), JSON.stringify(sc.savedDate));
|
||
if (sc.colWidths) localStorage.setItem(fileKey("colw"), JSON.stringify(sc.colWidths));
|
||
render();
|
||
const clamp = {};
|
||
Object.keys(cardEls).forEach((k) => { clamp[k] = cardEls[k].querySelector(".title").style.webkitLineClamp; });
|
||
return {
|
||
visible: VIS,
|
||
emptyReason: VIS.nodes.length ? null : emptyReason(),
|
||
layout: {
|
||
rel, laneOf, colBounds, baseW, pos, clamp,
|
||
lanes: lanesGeom.map((g) => ({ name: g.name, y: g.y, h: g.h, minH: g.minH, folded: g.folded })),
|
||
board: { width: board.style.width, height: board.style.height },
|
||
dateCols: dateEls.map((e) => ({ className: e.className, left: e.style.left, width: e.style.width })),
|
||
dateLabels: $("datesTrack").children.filter((e) => e.className.startsWith("dlabel"))
|
||
.map((e) => ({ className: e.className, left: e.style.left, title: e.title, innerHTML: e.innerHTML })),
|
||
edges: VIS.links.filter((l) => pos[l.src] && pos[l.dst])
|
||
.map((l) => ({ src: l.src, dst: l.dst, type: l.type, hand: l.hand, crit: l.crit, d: edgePath(pos[l.src], pos[l.dst]) })),
|
||
},
|
||
};
|
||
},
|
||
exportAfter(model, sc) { this.scenario(model, sc); return exportJson(); },
|
||
importJson(text, name) {
|
||
reset();
|
||
const r = fromJson(text, name);
|
||
applyJsonSettings(r);
|
||
return { import: r, storage: dump(), S: { neighbors: S.neighbors, onlyLinked: S.onlyLinked, onlyHand: S.onlyHand,
|
||
crit: S.crit, byDate: S.byDate, hideKind: S.hideKind, hideEdge: S.hideEdge } };
|
||
},
|
||
storage(before, op) {
|
||
localStorage.clear();
|
||
Object.entries(before).forEach(([k, v]) => localStorage.setItem(k, v));
|
||
if (op === "startup") {
|
||
const old = store.get(pref("mode"), null);
|
||
if (old !== null) { if (old === "linked") store.set(pref("neighbors"), "1"); if (old === "all") store.set(fileKey("sprint"), "[]"); store.del(pref("mode")); }
|
||
}
|
||
migrateStore();
|
||
return dump();
|
||
},
|
||
edgePath: (a, b) => edgePath(a, b),
|
||
orthoPath: (pts) => orthoPath(pts),
|
||
align(posIn, key, r, resizing) {
|
||
pos = posIn; guidesSvg.innerHTML = "";
|
||
const out = alignEdges(key, r, resizing);
|
||
return { out, guides: guidesSvg.children.map((l) => l.attrs) };
|
||
},
|
||
};`, ctx);
|
||
|
||
type G = Record<string, (...a: unknown[]) => unknown>;
|
||
const g = vm.runInContext("__g", ctx) as G;
|
||
const scenarios = JSON.parse(read("scenarios.json")) as { name: string }[];
|
||
|
||
const basic = g.fromHub(JSON.parse(read("hub-basic.board.json")));
|
||
write("hub-basic.expected.json", {
|
||
dicts: g.dicts(),
|
||
model: basic,
|
||
facets: { default: g.facets(basic), rememberedAll: g.facets(basic, []), rememberedSome: g.facets(basic, ["AWPCA_Спринт 3", "нет такого"]) },
|
||
scenarios: Object.fromEntries(scenarios.map((sc) => [sc.name, g.scenario(basic, sc)])),
|
||
export: g.exportAfter(basic, scenarios[0]),
|
||
});
|
||
const empty = g.fromHub(JSON.parse(read("hub-empty.board.json")));
|
||
write("hub-empty.expected.json", { model: empty, scenario: g.scenario(empty, scenarios[0]) });
|
||
|
||
const csv = read("jira-export.csv");
|
||
write("jira-export.expected.json", { rows: g.parseCsv(csv), model: g.fromCsv(csv, "jira-export.csv") });
|
||
write("jira-no-links.expected.json", { model: g.fromCsv(read("jira-no-links.csv"), "jira-no-links.csv") });
|
||
let bad: string | null = null;
|
||
try { g.fromCsv(read("jira-bad.csv"), "jira-bad.csv"); } catch (e) { bad = (e as Error).message; }
|
||
write("jira-bad.expected.json", { error: bad });
|
||
write("old-board.expected.json", g.importJson(read("old-board.json"), "old-board.json"));
|
||
|
||
const geo = JSON.parse(read("geometry.cases.json")) as {
|
||
edge: { a: unknown; b: unknown }[]; ortho: unknown[]; align: { pos: unknown; key: string; r: unknown; resizing: boolean }[];
|
||
};
|
||
write("geometry.expected.json", {
|
||
edge: geo.edge.map((c) => g.edgePath(c.a, c.b)),
|
||
ortho: geo.ortho.map((p) => g.orthoPath(p)),
|
||
align: geo.align.map((c) => g.align(c.pos, c.key, c.r, c.resizing)),
|
||
});
|
||
const cases = JSON.parse(read("storage.cases.json")) as { name: string; op: string; before: unknown }[];
|
||
write("storage.expected.json", Object.fromEntries(cases.map((c) => [c.name, g.storage(c.before, c.op)])));
|
||
console.log("эталоны записаны в", FIX.pathname);
|