|
| 1 | +import { test, expect } from "@playwright/test"; |
| 2 | + |
| 3 | +async function loadDemo(page, uri = "demo_decay") { |
| 4 | + await page.goto("/"); |
| 5 | + await page.waitForFunction(() => (window as any).hiplot?.render, { timeout: 15000 }); |
| 6 | + await expect(page.locator("#hiplot_element_id")).toBeVisible(); |
| 7 | + const input = page.locator('textarea[placeholder="Experiments to load"]'); |
| 8 | + await expect(input).toBeVisible(); |
| 9 | + await input.fill(uri); |
| 10 | + await input.press("Enter"); |
| 11 | + await page.waitForSelector("svg g.dimension", { state: "attached", timeout: 15000 }); |
| 12 | + await expect(page.locator("text=Loading HiPlot...")).toHaveCount(0); |
| 13 | +} |
| 14 | + |
| 15 | +/** Find the index of a parallel-plot dimension by its label text. */ |
| 16 | +async function findDimIndex(page, labelName: string): Promise<number> { |
| 17 | + return page.evaluate((name: string) => { |
| 18 | + const dims = document.querySelectorAll("svg g.dimension"); |
| 19 | + for (let i = 0; i < dims.length; i++) { |
| 20 | + const label = dims[i].querySelector(".pplot-label"); |
| 21 | + if (!label) continue; |
| 22 | + // Extract only direct text nodes, ignoring <title> children |
| 23 | + const text = Array.from(label.childNodes) |
| 24 | + .filter((n) => n.nodeType === Node.TEXT_NODE) |
| 25 | + .map((n) => (n.textContent || "").trim()) |
| 26 | + .join(""); |
| 27 | + if (text === name) return i; |
| 28 | + } |
| 29 | + return -1; |
| 30 | + }, labelName); |
| 31 | +} |
| 32 | + |
| 33 | +/** Get tick label texts for a parallel-plot dimension (excluding nan/inf/null). */ |
| 34 | +async function getTickLabels(page, dimIndex: number): Promise<string[]> { |
| 35 | + return page.evaluate((idx: number) => { |
| 36 | + const dim = document.querySelectorAll("svg g.dimension")[idx]; |
| 37 | + if (!dim) return []; |
| 38 | + const ticks = Array.from(dim.querySelectorAll(".tick text")); |
| 39 | + return ticks |
| 40 | + .map((t) => (t.textContent || "").trim()) |
| 41 | + .filter((t) => t.length > 0 && t !== "nan/inf/null"); |
| 42 | + }, dimIndex); |
| 43 | +} |
| 44 | + |
| 45 | +/** |
| 46 | + * Get bounding boxes of all tick labels within a parallel-plot dimension, |
| 47 | + * sorted top-to-bottom by vertical midpoint. |
| 48 | + */ |
| 49 | +async function getTickBBoxes( |
| 50 | + page, |
| 51 | + dimIndex: number, |
| 52 | +): Promise<{ text: string; top: number; bottom: number }[]> { |
| 53 | + return page.evaluate((idx: number) => { |
| 54 | + const dim = document.querySelectorAll("svg g.dimension")[idx]; |
| 55 | + if (!dim) return []; |
| 56 | + const ticks = Array.from(dim.querySelectorAll(".tick text")); |
| 57 | + return ticks |
| 58 | + .map((el) => { |
| 59 | + const r = (el as Element).getBoundingClientRect(); |
| 60 | + return { text: (el.textContent || "").trim(), top: r.top, bottom: r.bottom }; |
| 61 | + }) |
| 62 | + .filter((t) => t.text.length > 0) |
| 63 | + .sort((a, b) => (a.top + a.bottom) / 2 - (b.top + b.bottom) / 2); |
| 64 | + }, dimIndex); |
| 65 | +} |
| 66 | + |
| 67 | +/** |
| 68 | + * Check whether adjacent tick labels overlap vertically. |
| 69 | + * Returns the fraction of adjacent pairs that overlap. |
| 70 | + */ |
| 71 | +function overlapFraction(bboxes: { top: number; bottom: number }[]): number { |
| 72 | + if (bboxes.length < 2) return 0; |
| 73 | + let overlapping = 0; |
| 74 | + for (let i = 0; i < bboxes.length - 1; i++) { |
| 75 | + if (bboxes[i].bottom > bboxes[i + 1].top + 1) { |
| 76 | + overlapping++; |
| 77 | + } |
| 78 | + } |
| 79 | + return overlapping / (bboxes.length - 1); |
| 80 | +} |
| 81 | + |
| 82 | +test.describe("parallel plot log-scale tick labels", () => { |
| 83 | + test("log-scale column uses scientific notation tick labels", async ({ page }) => { |
| 84 | + await loadDemo(page); |
| 85 | + const dimIndex = await findDimIndex(page, "remaining_grams"); |
| 86 | + expect(dimIndex).toBeGreaterThanOrEqual(0); |
| 87 | + const labels = await getTickLabels(page, dimIndex); |
| 88 | + expect(labels.length).toBeGreaterThan(0); |
| 89 | + // All labels should be in scientific notation (e.g. "1.0e-5") |
| 90 | + for (const label of labels) { |
| 91 | + expect(label).toMatch(/e[+-]?\d+/i); |
| 92 | + } |
| 93 | + }); |
| 94 | + |
| 95 | + test("log-scale tick labels do not overlap", async ({ page }) => { |
| 96 | + await loadDemo(page); |
| 97 | + const dimIndex = await findDimIndex(page, "remaining_grams"); |
| 98 | + expect(dimIndex).toBeGreaterThanOrEqual(0); |
| 99 | + const bboxes = await getTickBBoxes(page, dimIndex); |
| 100 | + expect(bboxes.length).toBeGreaterThan(1); |
| 101 | + expect(overlapFraction(bboxes)).toBe(0); |
| 102 | + }); |
| 103 | + |
| 104 | + test("non-log columns are not corrupted by shared axis state", async ({ page }) => { |
| 105 | + await loadDemo(page); |
| 106 | + // Verify that plain numeric columns have tick labels that are NOT all |
| 107 | + // in scientific notation (regression: shared axis tickValues leak) |
| 108 | + const plainCols = ["initial_mass", "half_life", "elapsed_years"]; |
| 109 | + for (const col of plainCols) { |
| 110 | + const dimIndex = await findDimIndex(page, col); |
| 111 | + expect(dimIndex, `column ${col} not found in parallel plot`).toBeGreaterThanOrEqual(0); |
| 112 | + const labels = await getTickLabels(page, dimIndex); |
| 113 | + expect(labels.length, `no tick labels for ${col}`).toBeGreaterThan(0); |
| 114 | + const allSci = labels.every((l) => /e[+-]?\d+/i.test(l)); |
| 115 | + expect(allSci, `${col} tick labels are all scientific notation: ${labels.join(", ")}`).toBe( |
| 116 | + false, |
| 117 | + ); |
| 118 | + } |
| 119 | + }); |
| 120 | + |
| 121 | + test("non-log columns tick labels do not overlap", async ({ page }) => { |
| 122 | + await loadDemo(page); |
| 123 | + const plainCols = ["initial_mass", "half_life", "elapsed_years"]; |
| 124 | + for (const col of plainCols) { |
| 125 | + const dimIndex = await findDimIndex(page, col); |
| 126 | + expect(dimIndex).toBeGreaterThanOrEqual(0); |
| 127 | + const bboxes = await getTickBBoxes(page, dimIndex); |
| 128 | + expect(bboxes.length).toBeGreaterThan(1); |
| 129 | + const overlap = overlapFraction(bboxes); |
| 130 | + expect(overlap, `tick labels overlap on ${col}`).toBe(0); |
| 131 | + } |
| 132 | + }); |
| 133 | +}); |
| 134 | + |
| 135 | +test.describe("distribution plot log-scale tick labels", () => { |
| 136 | + test("log-scale distribution axis uses scientific notation and does not overlap", async ({ |
| 137 | + page, |
| 138 | + }) => { |
| 139 | + await loadDemo(page); |
| 140 | + // Open the distribution plot for remaining_grams via context menu |
| 141 | + const labels = page.locator(".pplot-label"); |
| 142 | + await expect(labels.first()).toBeVisible(); |
| 143 | + const labelTexts = await labels.evaluateAll((nodes) => |
| 144 | + nodes.map((node) => { |
| 145 | + const textParts = Array.from(node.childNodes) |
| 146 | + .filter((child) => child.nodeType === Node.TEXT_NODE) |
| 147 | + .map((child) => (child.textContent || "").trim()) |
| 148 | + .filter((text) => text.length > 0); |
| 149 | + return textParts.join(" ").trim(); |
| 150 | + }), |
| 151 | + ); |
| 152 | + const targetIndex = labelTexts.findIndex((t) => t === "remaining_grams"); |
| 153 | + expect(targetIndex).toBeGreaterThanOrEqual(0); |
| 154 | + |
| 155 | + await labels.nth(targetIndex).click({ button: "right" }); |
| 156 | + await expect(page.locator(".context-menu.show")).toBeVisible(); |
| 157 | + await page.locator(".context-menu .dropdown-item", { hasText: "View distribution" }).click(); |
| 158 | + await expect( |
| 159 | + page.locator('[data-testid="distribution-plot"][data-axis="remaining_grams"]'), |
| 160 | + ).toBeVisible(); |
| 161 | + |
| 162 | + const result = await page.evaluate(() => { |
| 163 | + const root = document.querySelector( |
| 164 | + '[data-testid="distribution-plot"][data-axis="remaining_grams"]', |
| 165 | + ); |
| 166 | + if (!root) return { error: "no-distribution-plot" }; |
| 167 | + // The data axis is axisBottom in vertical mode |
| 168 | + const axisTicks = Array.from(root.querySelectorAll(".axisBottom .tick text")); |
| 169 | + const tickData = axisTicks |
| 170 | + .map((t) => { |
| 171 | + const r = (t as Element).getBoundingClientRect(); |
| 172 | + return { |
| 173 | + text: (t.textContent || "").trim(), |
| 174 | + left: r.left, |
| 175 | + right: r.right, |
| 176 | + }; |
| 177 | + }) |
| 178 | + .filter((t) => t.text.length > 0) |
| 179 | + .sort((a, b) => a.left - b.left); |
| 180 | + const sciCount = tickData.filter((l) => /e[+-]?\d+/i.test(l.text)).length; |
| 181 | + // Check horizontal overlap for bottom axis |
| 182 | + let overlapCount = 0; |
| 183 | + for (let i = 0; i < tickData.length - 1; i++) { |
| 184 | + if (tickData[i].right > tickData[i + 1].left + 1) { |
| 185 | + overlapCount++; |
| 186 | + } |
| 187 | + } |
| 188 | + return { |
| 189 | + count: tickData.length, |
| 190 | + sciCount, |
| 191 | + overlapCount, |
| 192 | + sampleLabels: tickData.slice(0, 5).map((l) => l.text), |
| 193 | + }; |
| 194 | + }); |
| 195 | + expect(result.error).toBeUndefined(); |
| 196 | + expect(result.count).toBeGreaterThan(1); |
| 197 | + // All labels should be scientific notation |
| 198 | + expect(result.sciCount).toBe(result.count); |
| 199 | + // No overlapping labels |
| 200 | + expect(result.overlapCount).toBe(0); |
| 201 | + }); |
| 202 | +}); |
0 commit comments