import { expect, test, type Page } from "@playwright/test"; import { buildResultCsvBlob } from "../../src/resultCsvEncoding"; import { prepareApp, resultSnapshot } from "./fixtures"; test("CSV preserves BOM, CRLF, escaped raw keys and every binary64 value", async () => { const numbers = [-0, 0, Number.MIN_VALUE, -Number.MIN_VALUE, Number.MAX_VALUE, -Number.MAX_VALUE, 1 + Number.EPSILON, 1e21, 1e-7, Math.PI, 403499745569.73364]; const keys = ["time", '力,"端口"\r\nN']; const blob = buildResultCsvBlob(keys, numbers.length, new Float64Array([...numbers, ...numbers])); const bytes = new Uint8Array(await blob.arrayBuffer()); expect([...bytes.slice(0, 3)]).toEqual([0xef, 0xbb, 0xbf]); const text = new TextDecoder().decode(bytes); const header = 'time,"力,""端口""\r\nN"\r\n'; expect(text.startsWith(header)).toBe(true); const rows = text.slice(header.length).split("\r\n"); expect(rows.pop()).toBe(""); expect(rows).toHaveLength(numbers.length); rows.forEach((row, i) => row.split(",").forEach((value) => expect(Object.is(Number(value), numbers[i])).toBe(true))); expect(blob.type).toBe("text/csv;charset=utf-8"); expect(() => buildResultCsvBlob(["time"], 1, new Float64Array([Infinity]))).toThrow("non-finite"); expect(() => buildResultCsvBlob(["time"], 2, new Float64Array([0]))).toThrow("incomplete"); }); async function probePage(page: Page) { await page.route("**/csv-worker-probe", (route) => route.fulfill({ contentType: "text/html", body: "CSV worker probe" })); await page.goto("/csv-worker-probe"); } test("real Worker exports over one million raw cells exactly in bounded transfers", async ({ page }) => { test.setTimeout(60_000); await probePage(page); let csvRequests = 0; page.on("request", (request) => { if (request.url().includes("/api/simulation-results/csv")) csvRequests++; }); const result = await page.evaluate(async () => { // @ts-expect-error Vite serves this browser module. const { exportResultCsv } = await import("/src/resultCsvExport.ts"); const OriginalWorker = Worker; let workerCount = 0, terminated = 0, maxTransferBytes = 0, transferCount = 0; window.Worker = class extends OriginalWorker { constructor(url: string | URL, options?: WorkerOptions) { super(url, options); workerCount++; } postMessage(message: any, transfer?: any) { if (message.type === "chunk") { maxTransferBytes = Math.max(maxTransferBytes, message.values.byteLength); transferCount++; } super.postMessage(message, transfer); } terminate() { terminated++; super.terminate(); } }; const count = 4097; const variables = Array.from({ length: 256 }, (_, i) => ({ key: `component_${255-i}.pressure`, unit: "Pa", order: i })); const series: Record = { time: Array.from({ length: count }, (_, i) => i / 1000) }; series.time[1] = 0; // Duplicate and event samples are data, not rows to remove. series.time[2100] = 2.099000001; for (let column = variables.length - 1; column >= 0; column--) { series[variables[column].key] = Array.from({ length: count }, (_, i) => Math.sin(i / 17) * (column + 1)); } const extremes = [-0, Number.MIN_VALUE, Number.MAX_VALUE, 1 + Number.EPSILON, 403499745569.73364]; extremes.forEach((value, i) => { series[variables[0].key][i] = value; }); let ticks = 0; const timer = setInterval(() => ticks++, 0); const started = performance.now(); const blob = await exportResultCsv({ variables, series }); const elapsedMs = performance.now() - started; clearInterval(timer); window.Worker = OriginalWorker; const bytes = new Uint8Array(await blob.arrayBuffer()); const text = new TextDecoder().decode(bytes); const lines = text.split("\r\n"); const expectedKeys = ["time", ...variables.map((v) => v.key)]; const ordered = lines.shift() === expectedKeys.join(","); const trailingNewline = lines.pop() === ""; let checked = 0, exact = true; lines.forEach((line, row) => line.split(",").forEach((value, column) => { exact &&= Object.is(Number(value), series[expectedKeys[column]][row]); checked++; })); return { ordered, trailingNewline, exact, checked, rowCount: lines.length, bom: [...bytes.slice(0,3)], workerCount, terminated, maxTransferBytes, transferCount, ticks, elapsedMs }; }); expect(result).toMatchObject({ ordered: true, trailingNewline: true, exact: true, checked: 257 * 4097, rowCount: 4097, bom: [239,187,191], workerCount: 1, terminated: 1 }); expect(result.maxTransferBytes).toBeLessThanOrEqual(1024 * 1024); expect(result.transferCount).toBeGreaterThan(1); expect(result.ticks).toBeGreaterThan(1); expect(csvRequests).toBe(0); console.log(JSON.stringify({ probe: "csv-worker-roundtrip", ...result })); }); test("CSV rejects bad metadata/data and releases Workers after failure or cancellation", async ({ page }) => { await probePage(page); const result = await page.evaluate(async () => { // @ts-expect-error Vite serves this browser module. const { exportResultCsv, resultCsvFilename } = await import("/src/resultCsvExport.ts"); const OriginalWorker = Worker; let created = 0, terminated = 0; window.Worker = class extends OriginalWorker { constructor(url: string | URL, options?: WorkerOptions) { super(url, options); created++; } terminate() { terminated++; super.terminate(); } }; const good = () => ({ variables: [{ key: "p" }], series: { time: [0,1], p: [1,2] } }); const errors: string[] = []; for (const input of [ { variables: [{ key: "p" }], series: { p: [1] } }, { variables: [], series: { time: [0] } }, { variables: [{ key: "p" }, { key: "p" }], series: { time: [0], p: [1] } }, { variables: [{ key: "p" }], series: { time: [0], extra: [1] } }, { variables: [{ key: "p" }], series: { time: [0], p: [1,2] } }, { variables: [{ key: "p" }], series: { time: [0], p: [Infinity] } }, { variables: [{ key: "p" }], series: { time: [0], p: [null] } }, ]) { try { await exportResultCsv(input); errors.push("unexpected success"); } catch (error) { errors.push((error as Error).message); } } const aborted = new AbortController(); aborted.abort(); const names: string[] = []; try { await exportResultCsv(good(), aborted.signal); } catch (error) { names.push((error as Error).name); } const running = new AbortController(); const task = exportResultCsv(good(), running.signal); running.abort(); try { await task; } catch (error) { names.push((error as Error).name); } await exportResultCsv(good()); // Failure and abort must not poison a retry. window.Worker = OriginalWorker; return { errors, names, created, terminated, filename: resultCsvFilename(' .储气:/系统. '), unicodeLength: resultCsvFilename("😀".repeat(90)).split("-results")[0].length }; }); expect(result.errors[0]).toContain("non-empty time"); expect(result.errors[1]).toContain("exportable variables"); expect(result.errors[2]).toContain("duplicate"); expect(result.errors[3]).toContain("missing p; unmapped extra"); expect(result.errors[4]).toContain("inconsistent length"); expect(result.errors[5]).toContain("non-finite"); expect(result.errors[6]).toContain("non-numeric"); expect(result.names).toEqual(["AbortError", "AbortError"]); expect(result.created).toBe(4); expect(result.terminated).toBe(result.created); expect(result.filename).toBe("储气__系统-results.csv"); expect(result.unicodeLength).toBe(160); }); async function openResults(page: Page) { await prepareApp(page); await page.addInitScript((snapshot) => { sessionStorage.setItem("system-simulation-flow:latest-result", JSON.stringify(snapshot)); }, resultSnapshot); await page.goto("/"); await page.getByRole("tab", { name: "结果", exact: true }).click(); await expect(page.getByRole("button", { name: "下载结果 CSV", exact: true })).toBeEnabled(); } test("CSV toolbar downloads current raw data locally without HTTP", async ({ page }) => { await openResults(page); let requests = 0; page.on("request", (request) => { if (request.url().includes("/api/simulation-results/csv")) requests++; }); const downloading = page.waitForEvent("download"); await page.getByRole("button", { name: "下载结果 CSV", exact: true }).click(); const download = await downloading; const stream = await download.createReadStream(); const chunks: Buffer[] = []; for await (const chunk of stream!) chunks.push(Buffer.from(chunk)); expect(Buffer.concat(chunks).toString("utf8")).toBe("\ufefftime,generic_sensor_1.value\r\n0,0\r\n5,0.5\r\n10,1\r\n"); await expect(page.getByRole("button", { name: "下载结果 CSV", exact: true })).toBeEnabled(); expect(requests).toBe(0); }); async function installHeldWorker(page: Page) { await page.evaluate(() => { const original = Worker; (window as any).__csvWorkers = []; window.Worker = function(url: string | URL, options?: WorkerOptions) { if (!String(url).includes("resultCsv.worker")) return new original(url, options); const worker = { onmessage: null, onerror: null, onmessageerror: null, terminated: 0, postMessage() {}, terminate() { this.terminated++; } }; (window as any).__csvWorkers.push(worker); return worker; } as any; }); } test("CSV toolbar blocks duplicate clicks and cancels a download when leaving results", async ({ page }) => { await openResults(page); await installHeldWorker(page); const button = page.getByRole("button", { name: "下载结果 CSV", exact: true }); let downloads = 0; page.on("download", () => downloads++); await button.evaluate((element: HTMLButtonElement) => { element.click(); element.click(); }); await expect(button).toBeDisabled(); expect(await page.evaluate(() => (window as any).__csvWorkers.length)).toBe(1); await page.getByRole("tab", { name: "建模", exact: true }).click(); expect(await page.evaluate(() => (window as any).__csvWorkers[0].terminated)).toBe(1); await page.evaluate(() => (window as any).__csvWorkers[0].onmessage({ data: { type: "complete", blob: new Blob(["stale"]) } })); expect(downloads).toBe(0); await page.getByRole("tab", { name: "结果", exact: true }).click(); await expect(button).toBeEnabled(); }); test("CSV Worker errors release resources and allow the toolbar to retry", async ({ page }) => { await openResults(page); await installHeldWorker(page); const button = page.getByRole("button", { name: "下载结果 CSV", exact: true }); await button.click(); const dialog = page.waitForEvent("dialog"); const fail = page.evaluate(() => (window as any).__csvWorkers[0].onerror({ message: "injected worker error", preventDefault() {} })); const alert = await dialog; expect(alert.message()).toBe("CSV 下载失败:injected worker error"); await alert.accept(); await fail; await expect(button).toBeEnabled(); expect(await page.evaluate(() => (window as any).__csvWorkers[0].terminated)).toBe(1); await button.click(); expect(await page.evaluate(() => (window as any).__csvWorkers.length)).toBe(2); });