Files
OpenResearch/ui/tests/experimentLineageView.test.mjs
T
cbbde3890f Add lineage navigation to experiment table and overview (#570)
* Add a History view of experiment lineage and run attempts

Co-authored-by: lc140159_gsk <211554985+lc140159_gsk@users.noreply.github.com>

* Address review: archived filter, overlay focus, fold counts and narrow layout

1. History ignored "Show archived experiments". App now passes the
   setting, and History hides archived experiments unless it is on while
   still building lineage from every experiment, so a shown child of an
   archived parent keeps its "from ..." lead-in. When every experiment in
   scope is archived, History shows the same hint as the table.
2. In a narrow view the detail pane covered the list but focus stayed on
   a covered row. The covered list is now inert until the pane closes,
   and the pane takes focus when it covers the list. Closing it still
   returns focus to the row.
3. A folded branch counted later same-chapter experiments reached
   through another chapter, which also render as their own roots. The
   fold now follows the same in-chapter children the layout draws.
4. Search could fold a side branch holding a match. Nothing folds while
   a search is active.
5. The From column stayed at compact widths. It now follows the compact
   rule like Attempts and Latest.
6. Chapter headers kept status counts and dates on one line at compact
   widths, squeezing the title. The summary is dropped at compact widths.

The width rules move into historyFit() in the model so they can be
tested without a browser.

Co-authored-by: lc140159_gsk <211554985+lc140159_gsk@users.noreply.github.com>

* Add Polish translations for the new strings

Upstream added Polish as a ninth catalogue (#626), so the strings this
change introduces need a Polish entry for the catalogue lint to pass.

Co-authored-by: lc140159_gsk <211554985+lc140159_gsk@users.noreply.github.com>

* Integrate lineage into experiment table and overview

* Trim trailing whitespace in lineage component

* Hide experiment lineage by default

* Preserve tab gestures in experiment lineage links

---------

Co-authored-by: lc140159_gsk <211554985+lc140159_gsk@users.noreply.github.com>
Co-authored-by: Daniel Kim <sox8502@gmail.com>
2026-10-18 22:12:30 -07:00

107 lines
4.5 KiB
JavaScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import ts from "typescript";
const require = createRequire(import.meta.url);
const en = JSON.parse(readFileSync(new URL("../messages/en.json", import.meta.url), "utf8"));
const messages = { m: new Proxy({}, { get: (_, key) => (values = {}) => en[key].replace(/\{(\w+)\}/g, (_, name) => String(values[name])) }) };
const api = { fmtNumber: String, timeAgo: () => "1d ago", runDisplayStatus: (run) => run.status };
const runtime = { getLocale: () => "en" };
function load(file, mocks) {
const source = readFileSync(new URL(`../src/components/${file}`, import.meta.url), "utf8");
const code = ts.transpileModule(source, {
fileName: file,
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX },
}).outputText;
const exports = {};
new Function("require", "exports", code)((name) => mocks[name] ?? require(name), exports);
return exports;
}
const model = load("experimentLineageModel.ts", {
"../api": api,
"../i18n": { ltr: (text) => text },
"../paraglide/messages.js": messages,
});
const tabPreview = load("../tabPreview.ts", {});
function lineage(expanded) {
return load("ExperimentLineage.tsx", {
react: { ...React, useState: () => [expanded, () => {}], useEffect: () => {} },
"lucide-react": { ChevronRight: () => null },
"../api": api,
"../paraglide/messages.js": messages,
"../paraglide/runtime.js": runtime,
"./experimentLineageModel": model,
"../tabPreview": tabPreview,
});
}
const experiments = Array.from({ length: 8 }, (_, index) => ({
id: String(index),
title: `Experiment ${index}`,
slug: `experiment-${index}`,
createdAt: index,
parentExperimentId: index ? String(index - 1) : null,
}));
const nodes = model.buildHistoryNodes(experiments, []);
test("lineage shows four ancestors and the current experiment before folding earlier ancestors", () => {
const { ExperimentLineage } = lineage(false);
const html = renderToStaticMarkup(React.createElement(ExperimentLineage, { node: nodes[6], onSelect: () => {} }));
assert.match(html, /aria-expanded="false"/);
assert.match(html, /2 earlier:/);
for (let index = 2; index <= 6; index++) assert.match(html, new RegExp(`>Experiment ${index}<`));
assert.doesNotMatch(html, />Experiment 0</);
assert.match(html, /Children/);
assert.match(html, />Experiment 7</);
assert.match(html, /aria-current="true"/);
});
test("expanded lineage retains a collapse control and shows every ancestor", () => {
const { ExperimentLineage } = lineage(true);
const html = renderToStaticMarkup(React.createElement(ExperimentLineage, { node: nodes[6], onSelect: () => {} }));
assert.match(html, /aria-expanded="true"/);
assert.match(html, /Show less/);
for (let index = 0; index <= 6; index++) assert.match(html, new RegExp(`>Experiment ${index}<`));
});
test("short lineage has no unnecessary expand control", () => {
const { ExperimentLineage } = lineage(false);
const html = renderToStaticMarkup(React.createElement(ExperimentLineage, { node: nodes[4], onSelect: () => {} }));
assert.doesNotMatch(html, /aria-expanded/);
for (let index = 0; index <= 4; index++) assert.match(html, new RegExp(`>Experiment ${index}<`));
});
test("ancestor and child rows preserve preview and keep-open gestures", () => {
const { ExperimentLineage } = lineage(false);
const opened = [];
const tree = ExperimentLineage({ node: nodes[6], onSelect: (...args) => opened.push(args) });
const buttons = [];
function visit(element) {
if (Array.isArray(element)) return element.forEach(visit);
if (!React.isValidElement(element)) return;
if (typeof element.type === "function") return visit(element.type(element.props));
if (element.type === "button") buttons.push(element.props);
visit(element.props.children);
}
visit(tree);
for (const id of ["5", "7"]) {
const row = buttons.find((props) => props.children?.[2]?.props?.children === `Experiment ${id}`);
assert.ok(row, `missing lineage row ${id}`);
row.onClick({});
row.onDoubleClick({});
row.onAuxClick({ button: 1, preventDefault() {} });
row.onKeyDown({ key: "Enter", preventDefault() {} });
row.onKeyDown({ key: " ", preventDefault() {} });
assert.deepEqual(opened.splice(0), [
[id, "preview"], [id, "keepOpen"], [id, "keepOpen"], [id, "keepOpen"], [id, "preview"],
]);
}
});