sdlc-hub/web/scripts/golden-from-links.ts
Andrey Epifancev 2cb454af8b refactor(web): модуль jira и оболочка из списка модулей (#87)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:05:02 +04:00

196 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// разовый генератор golden-эталонов доски: исполняет <script> страницы links.html ревизии 3931a88 в node:vm с заглушкой
// DOM и записывает результаты её функций на фикстурах в src/modules/jira/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/modules/jira/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);