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 })); });