import { expect, test, type Page } from "@playwright/test";
import { chartDisplaySamples, chartSampleBounds, preparedChartSamples, sampledChartPath } from "../../src/chartData";
import { resultSnapshot, prepareApp } from "./fixtures";
test("million-point plots preserve extrema and raw indices within a pixel-sized path", () => {
const time = Array.from({ length: 1_000_000 }, (_, i) => i * 0.001);
const values = time.map((t) => Math.sin(t));
values[123456] = 1e9;
values[654321] = -1e9;
const started = performance.now();
const samples = preparedChartSamples(time, values);
const displayed = chartDisplaySamples(samples, 0, 1000, 800);
expect(chartSampleBounds(samples)).toMatchObject({ yMin: -1e9, yMax: 1e9 });
expect(displayed.some((sample) => sample.dataIndex === 123456 && sample.y === 1e9)).toBe(true);
expect(displayed.some((sample) => sample.dataIndex === 654321 && sample.y === -1e9)).toBe(true);
expect(displayed.length).toBeLessThanOrEqual(4 * 802);
expect(preparedChartSamples(time, values)).toBe(samples);
expect(chartDisplaySamples(samples, 0, 1000, 800)).toBe(displayed);
const narrow = chartDisplaySamples(samples, 123.450, 123.465, 800);
expect(narrow.some((sample) => sample.dataIndex === 123456)).toBe(true);
expect(narrow.length).toBeLessThan(20);
expect(displayed.every((sample) => sample.y === values[sample.dataIndex])).toBe(true);
console.log(JSON.stringify({ probe: "million-point-chart", rawPoints: samples.length, displayPoints: displayed.length, elapsedMs: performance.now() - started }));
});
test("pixel decimation preserves gaps, duplicate-time jumps, boundaries and unsorted input", () => {
const time = Array.from({ length: 10000 }, (_, i) => i);
const values = time.map(() => 0);
time[4000] = Number.NaN;
time[5001] = time[5000];
values[5001] = 999;
const samples = preparedChartSamples(time, values);
const display = chartDisplaySamples(samples, 0, 10000, 10);
expect(display.some((sample) => sample.breakBefore && sample.dataIndex === 4001)).toBe(true);
expect(display.some((sample) => sample.dataIndex === 5001 && sample.y === 999)).toBe(true);
const path = sampledChartPath(time, values, (x) => x, (y) => y, 0, 10000, 10);
expect(path.match(/\bM /g)).toHaveLength(2);
const crossing = chartDisplaySamples(preparedChartSamples([0, 10], [-10, 10]), 4, 6, 10);
expect(crossing.map((sample) => sample.x)).toEqual([0, 10]);
const unsorted = preparedChartSamples([2, 0, 1], [20, 0, 10]);
expect(chartDisplaySamples(unsorted, 0, 2, 1)).toEqual(unsorted);
});
async function probePage(page: Page) {
await prepareApp(page);
await page.route("**/result-performance-probe", (route) => route.fulfill({ contentType: "text/html", body: "
Result probe" }));
await page.goto("/result-performance-probe");
}
test("chunked persistence restores one million doubles exactly beyond sessionStorage quota", async ({ page }) => {
await probePage(page);
const measurement = await page.evaluate(async (template) => {
// @ts-expect-error browser-side Vite module
const { storeResultSnapshot, loadStoredResultSnapshot, resultSavePending } = await import("/src/resultPersistence.ts");
const values = Array.from({ length: 1_000_000 }, (_, i) => Math.sin(i * 0.013) * 1e5);
const snapshot = { ...template, result: { ...template.result, series: { time: [0, 1], values, empty: [] } } };
const serialized = JSON.stringify(snapshot);
const originalStringify = JSON.stringify;
let largeStringifyCalls = 0;
JSON.stringify = function(value: unknown, ...args: any[]) {
if ((value as any)?.result?.series || (value as any)?.snapshot?.result?.series) largeStringifyCalls++;
return (originalStringify as any)(value, ...args);
} as typeof JSON.stringify;
let ticks = 0;
const timer = setInterval(() => { ticks++; }, 0);
const start = performance.now();
const saving = storeResultSnapshot(snapshot);
const savingImmediately = resultSavePending();
await saving;
const writeMs = performance.now() - start;
clearInterval(timer);
JSON.stringify = originalStringify;
const readStart = performance.now();
const loaded = await loadStoredResultSnapshot();
return {
rawJsonBytes: serialized.length, writeMs, readMs: performance.now() - readStart, ticks,
savingImmediately, pendingAfter: resultSavePending(), largeStringifyCalls,
markerBytes: sessionStorage.getItem("system-simulation-flow:latest-result")!.length,
equal: loaded.result.series.values.length === values.length && loaded.result.series.values.every((value: number, i: number) => value === values[i]),
empty: loaded.result.series.empty,
};
}, resultSnapshot);
expect(measurement).toMatchObject({ equal: true, savingImmediately: true, pendingAfter: false, largeStringifyCalls: 0, empty: [] });
expect(measurement.ticks).toBeGreaterThan(1);
expect(measurement.markerBytes).toBeLessThan(200);
console.log(JSON.stringify({ probe: "large-result-persistence", ...measurement }));
await page.reload();
expect(await page.evaluate(async () => {
// @ts-expect-error browser-side Vite module
const { loadStoredResultSnapshot } = await import("/src/resultPersistence.ts");
return (await loadStoredResultSnapshot()).result.series.values.length;
})).toBe(1_000_000);
});
test("failed and superseded saves preserve the last complete snapshot", async ({ page }) => {
await probePage(page);
const outcome = await page.evaluate(async (template) => {
// @ts-expect-error browser-side Vite module
const { storeResultSnapshot, loadStoredResultSnapshot, resultSavePending } = await import("/src/resultPersistence.ts");
await storeResultSnapshot(template);
const firstMarker = sessionStorage.getItem("system-simulation-flow:latest-result");
const originalPut = IDBObjectStore.prototype.put;
IDBObjectStore.prototype.put = function() { throw new DOMException("Injected quota failure", "QuotaExceededError"); };
let failed = false;
try { await storeResultSnapshot({ ...template, id: "failed" }); } catch { failed = true; }
IDBObjectStore.prototype.put = originalPut;
const preserved = firstMarker === sessionStorage.getItem("system-simulation-flow:latest-result") && (await loadStoredResultSnapshot()).id === template.id;
const old = storeResultSnapshot({ ...template, id: "older", result: { ...template.result, series: { time: Array.from({ length: 200000 }, (_, i) => i) } } });
const latest = storeResultSnapshot({ ...template, id: "newest" });
const saved = await Promise.all([old, latest]);
return { failed, preserved, saved, newest: (await loadStoredResultSnapshot()).id, pending: resultSavePending() };
}, resultSnapshot);
expect(outcome).toEqual({ failed: true, preserved: true, saved: [false, true], newest: "newest", pending: false });
});
async function prepareLargeResult(page: Page) {
await probePage(page);
await page.evaluate(async (template) => {
// @ts-expect-error browser-side Vite module
const { storeResultSnapshot } = await import("/src/resultPersistence.ts");
const count = 250000;
const time = Array.from({ length: count }, (_, i) => i * 0.001);
const series: Record = { time };
const variables = Array.from({ length: 3 }, (_, index) => ({
...template.result.variables[0], key: `generic_sensor_1.stress_${index}`, name: `stress_${index}`,
label: `压力${index}`, quantity: "pressure", unit: "Pa", order: index,
}));
variables.forEach((variable, index) => { series[variable.key] = time.map((t) => 100000 + Math.sin(t + index) * 1000); });
series[variables[0].key][123456] = 1e9;
const snapshot = { ...template, id: "large-chart-stress", result: { ...template.result, variables, series, diagnostics: { ...template.result.diagnostics, sampleCount: count } } };
await storeResultSnapshot(snapshot);
}, resultSnapshot);
await page.goto("/");
await page.getByRole("tab", { name: "结果", exact: true }).click();
await expect(page.locator(".results-variable-list button")).toHaveCount(3);
}
test("250,000-sample single, multi and stacked charts stay bounded and export full raw data", async ({ page }) => {
test.setTimeout(90000);
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await prepareLargeResult(page);
const variables = page.locator(".results-variable-list button");
await variables.first().click();
const single = page.locator('.result-chart-window[data-chart-kind="single"]');
await expect(single).toBeVisible();
const svg = single.locator('svg[data-result-chart="true"]');
expect(Number(await svg.getAttribute("data-view-y-max"))).toBeGreaterThan(1e9);
const firstPath = await single.locator('path[data-chart-curve="true"], g[data-chart-clipped-series="true"] path, path[data-chart-clipped-series="true"]').first().getAttribute("d");
expect((firstPath!.match(/\b[ML] /g) ?? []).length).toBeLessThan(5000);
await single.getByRole("combobox", { name: "压力0纵轴单位", exact: true }).selectOption("kPa");
expect(Number(await svg.getAttribute("data-view-y-max"))).toBeGreaterThan(1e6);
await single.getByRole("button", { name: "打开 压力0 曲线游标", exact: true }).click();
const readings = await single.locator(".result-chart-cursor-panel dd").allTextContents();
const readTime = Number.parseFloat(readings[0]);
const readValue = Number.parseFloat(readings[1]);
expect(readTime).toBe(125);
expect(readValue).toBeCloseTo((100000 + Math.sin(125) * 1000) / 1000, 4);
// X-axis wheel zoom must redraw only the viewport and keep the exact raw samples.
await single.getByRole("button", { name: "打开 压力0 曲线缩放", exact: true }).click();
const xAxis = svg.locator('[data-chart-zoom-axis="x"]');
const bounds = await xAxis.boundingBox();
expect(bounds).not.toBeNull();
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2);
await page.mouse.wheel(0, -160);
await expect(svg).toHaveAttribute("data-zoomed", "true");
await single.getByRole("button", { name: "恢复 压力0 原始尺寸", exact: true }).click();
await page.getByRole("button", { name: "新建同单位多曲线对比窗口", exact: true }).click();
const multi = page.locator('.result-chart-window[data-chart-kind="multi"]');
await variables.nth(0).dragTo(multi);
await variables.nth(1).dragTo(multi);
await expect(multi).toContainText("2/2 条显示");
await page.getByRole("button", { name: "新建异单位上下对比窗口", exact: true }).click();
const mixed = page.locator('.result-chart-window[data-chart-kind="mixed"]');
await variables.nth(1).dragTo(mixed);
await variables.nth(2).dragTo(mixed);
await expect(mixed).toContainText("2/2 条显示");
const paths = await page.locator('g[data-chart-clipped-series="true"] path, path[data-chart-clipped-series="true"]').evaluateAll((elements) => elements.map((element) => (element.getAttribute("d")?.match(/\b[ML] /g) ?? []).length));
expect(paths.length).toBeGreaterThanOrEqual(5);
expect(paths.every((count) => count < 10000)).toBe(true);
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "下载结果文件", exact: true }).click();
const download = await downloadPromise;
const stream = await download.createReadStream();
const chunks: Buffer[] = [];
for await (const chunk of stream!) chunks.push(Buffer.from(chunk));
const exported = JSON.parse(Buffer.concat(chunks).toString("utf8"));
expect(exported.snapshot.result.series.time).toHaveLength(250000);
expect(exported.snapshot.result.series["generic_sensor_1.stress_0"][123456]).toBe(1e9);
// Compare the exact original browser data, not Math.sin recomputed in Node's V8.
const rawMatches = await page.evaluate(async (exportedSeries) => {
// @ts-expect-error browser-side Vite module
const { loadStoredResultSnapshot } = await import("/src/resultPersistence.ts");
const raw = (await loadStoredResultSnapshot()).result.series;
return Object.entries(exportedSeries).every(([key, values]) => {
const numericValues = values as number[];
return numericValues.length === raw[key].length && numericValues.every((value, i) => Object.is(value, raw[key][i]));
});
}, exported.snapshot.result.series);
expect(rawMatches).toBe(true);
await page.reload();
await page.getByRole("tab", { name: "结果", exact: true }).click();
await expect(page.locator(".result-chart-window")).toHaveCount(3);
expect(errors).toEqual([]);
console.log(JSON.stringify({ probe: "large-multi-chart", pointsPerCurve: 250000, paths }));
});
test("browser chart benchmark keeps redraw paths small without resampling raw cursor data", async ({ page }) => {
await probePage(page);
const metrics = await page.evaluate(async () => {
// @ts-expect-error browser-side Vite module
const { sampledChartPath, preparedChartSamples } = await import("/src/chartData.ts");
// @ts-expect-error browser-side Vite module
const { segmentedChartPath } = await import("/src/resultEventSeries.ts");
const time = Array.from({ length: 250000 }, (_, i) => i * .001);
const values = time.map((x) => Math.sin(x));
const x = (value: number) => value / 250 * 800;
const y = (value: number) => 200 - value * 100;
const start = performance.now();
const oldPath = segmentedChartPath(time, values, x, y);
const oldMs = performance.now() - start;
const first = performance.now();
const newPath = sampledChartPath(time, values, x, y, 0, 250, 800);
const firstMs = performance.now() - first;
const redraw = performance.now();
for (let i = 0; i < 100; i++) sampledChartPath(time, values, x, y, 0, 250, 800);
const redrawMs = (performance.now() - redraw) / 100;
return { points: time.length, oldMs, firstMs, redrawMs, oldCommands: (oldPath.match(/\b[ML] /g) ?? []).length, newCommands: (newPath.match(/\b[ML] /g) ?? []).length, rawSamples: preparedChartSamples(time, values).length };
});
expect(metrics.rawSamples).toBe(250000);
expect(metrics.newCommands).toBeLessThan(3209);
expect(metrics.oldCommands).toBe(250000);
console.log(JSON.stringify({ probe: "browser-chart-comparison", ...metrics }));
});