// разовый генератор golden-эталонов доски: исполняет ")); // элемент-заглушка: хранит стили, атрибуты и детей; querySelector возвращает один и тот же элемент на селектор; // высота карточки 117 px, её заголовка (.title) 38 px, остальных элементов 0 class El { className = ""; hidden = false; disabled = false; title = ""; value = ""; textContent = ""; open = false; scrollLeft = 0; style: Record = { setProperty(k: string, v: string) { this[k] = v; }, removeProperty(k: string) { delete this[k]; }, }; dataset: Record = {}; attrs: Record = {}; children: El[] = []; sel = ""; private html = ""; private q = new Map(); 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(), bySel = new Map(); 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(); 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 = { 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 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);