diff --git a/CHANGELOG.md b/CHANGELOG.md index 78fe2fd0..b7a5e5ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ - Added a `toast` component with plain-text or Markdown content, icons, colors, six screen placements, configurable auto-dismiss timing, optional manual dismissal, URL-fragment triggers, and automatic stacking of queued notifications. - `sqlpage.send_mail` now supports rich email bodies. Use `body_html` for a caller-provided HTML alternative, or `body_md` to render Markdown as HTML. Messages retain a plain-text alternative; `body` may be omitted when `body_md` is used, and `body_md` and `body_html` cannot be combined. - Form `options_source` URLs now preserve existing query parameters when adding the dynamic `search` parameter. + - Map coordinates that are not a pair of numbers, like a latitude with no longitude, are now reported in the browser console and skipped, instead of breaking the whole map. + - Stacked charts now stack their series by `x` value instead of by point order, which used to give wrong totals when a series was missing a point. + - `line`, `area`, `scatter`, `bubble` and `heatmap` charts with text labels on the x axis now line their series up by label, leaving a gap where a series skips one. + - `column` charts now display vertical bars instead of nothing at all. + - `stacked` is now ignored on chart types that cannot stack, instead of displaying an empty chart. - Screen readers now announce the title of the modal component instead of an unnamed dialog. ## v0.45 diff --git a/build.rs b/build.rs index 6f9f4a96..0649afe7 100644 --- a/build.rs +++ b/build.rs @@ -74,6 +74,16 @@ async fn process_input_file(client: &awc::Client, path_out: &Path, original: Fil return; } outfile.write_all(b"\n").unwrap(); + } else if let Some(name) = line + .strip_prefix("/* !include ./") + .and_then(|rest| rest.strip_suffix(" */")) + { + let included_path = format!("sqlpage/{name}"); + println!("cargo:rerun-if-changed={included_path}"); + let included = std::fs::read(&included_path) + .unwrap_or_else(|e| panic!("Unable to read {included_path}: {e}")); + outfile.write_all(&included).unwrap(); + outfile.write_all(b"\n").unwrap(); } else { writeln!(outfile, "{line}").unwrap(); } diff --git a/examples/official-site/sqlpage/migrations/01_documentation.sql b/examples/official-site/sqlpage/migrations/01_documentation.sql index 5f1ee7ca..9e4ce254 100644 --- a/examples/official-site/sqlpage/migrations/01_documentation.sql +++ b/examples/official-site/sqlpage/migrations/01_documentation.sql @@ -650,7 +650,7 @@ INSERT INTO component(name, icon, description) VALUES INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'chart', * FROM (VALUES -- top level ('title', 'The name of the chart.', 'TEXT', TRUE, TRUE), - ('type', 'The type of chart. One of: "line", "area", "bar", "column", "pie", "scatter", "bubble", "heatmap", "rangeBar"', 'TEXT', TRUE, FALSE), + ('type', 'The type of chart. One of: "line", "area", "bar", "column", "pie", "scatter", "bubble", "heatmap", "rangeBar". "column" is a synonym of "bar".', 'TEXT', TRUE, FALSE), ('time', 'Whether the x-axis represents time. If set to true, the x values will be parsed and formatted as dates for the user.', 'BOOLEAN', TRUE, TRUE), ('xmin', 'The minimal value for the x-axis. When time is true, this can be a date or timestamp.', 'TEXT', TRUE, TRUE), ('xmax', 'The maximum value for the x-axis. When time is true, this can be a date or timestamp.', 'TEXT', TRUE, TRUE), @@ -664,7 +664,7 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S ('marker', 'Marker size', 'REAL', TRUE, TRUE), ('labels', 'Whether to show the data labels on the chart or not.', 'BOOLEAN', TRUE, TRUE), ('color', 'The name of a color in which to display the chart. If there are multiple series in the chart, this parameter can be repeated multiple times.', 'COLOR', TRUE, TRUE), - ('stacked', 'Whether to cumulate values from different series.', 'BOOLEAN', TRUE, TRUE), + ('stacked', 'Whether to cumulate values from different series. Supported by the "line", "area" and "bar" chart types, and ignored by the others. Series are aligned on their x values, and a series that has no value for a given x counts as zero there.', 'BOOLEAN', TRUE, TRUE), ('toolbar', 'Whether to display a toolbar at the top right of the chart, that offers downloading the data as CSV.', 'BOOLEAN', TRUE, TRUE), ('show_legend', 'Whether to display the legend listing all chart series. Defaults to true.', 'BOOLEAN', TRUE, TRUE), ('logarithmic', 'Display the y-axis in logarithmic scale.', 'BOOLEAN', TRUE, TRUE), @@ -717,6 +717,20 @@ INSERT INTO example(component, description, properties) VALUES '{"series": "Marketing", "x": 2022, "value": 15}, '|| '{"series": "Human resources", "x": 2021, "value": 30}, '|| '{"series": "Human resources", "x": 2022, "value": 55}]')), + ('chart', 'A stacked area chart, showing how each series contributes to a total. +The `stacked` property also works with the `line` and `bar` chart types. + +Series are aligned on their `x` values, and a series that has no value for a given `x` counts as zero there: +below, the graphics card draws no power outside of the render. +If a missing value does not mean zero in your data, make all the series share the same `x` values, +for instance by rounding timestamps to a common interval.', + json('[{"component":"chart", "title": "Power draw", "type": "area", "stacked": true, "time": true, "ytitle": "watts", "color": ["blue", "teal"], "marker": 4}, '|| + '{"series": "CPU", "x": "2024-03-01T10:00:00Z", "value": 45}, '|| + '{"series": "CPU", "x": "2024-03-01T10:15:00Z", "value": 52}, '|| + '{"series": "CPU", "x": "2024-03-01T10:30:00Z", "value": 48}, '|| + '{"series": "CPU", "x": "2024-03-01T10:45:00Z", "value": 44}, '|| + '{"series": "GPU", "x": "2024-03-01T10:15:00Z", "value": 120}, '|| + '{"series": "GPU", "x": "2024-03-01T10:30:00Z", "value": 140}]')), ('chart', 'A line chart with multiple series. One of the most common types of charts, often used to show trends over time. Also demonstrates the use of the `toolbar` attribute to allow the user to download the graph as an image or the data as a CSV file.', json('[{"component":"chart", "title": "Revenue", "ymin": 0, "toolbar": true}, diff --git a/package.json b/package.json index 924c4e4d..b69851a1 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "sqlpage", "version": "1.0.0", "scripts": { - "test": "biome check .", + "test": "biome check . && node --test \"tests/js/**/*.spec.ts\"", "format": "biome format --write .", "fix": "biome check --fix --unsafe ." }, diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js index f4fc1bcd..49278b40 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -1,4 +1,5 @@ /* !include https://cdn.jsdelivr.net/npm/apexcharts@5.13.0/dist/apexcharts.min.js */ +/* !include ./chart_series.js */ sqlpage_chart = (() => { function sqlpage_chart() { @@ -36,60 +37,12 @@ sqlpage_chart = (() => { ); const isDarkTheme = document.body?.dataset?.bsTheme === "dark"; - /** @typedef { { [name:string]: {data:{x:number|string|Date,y:number}[], name:string} } } Series */ + const STACKABLE_CHART_TYPES = ["line", "area", "bar"]; + const APEXCHARTS_TYPE_ALIASES = { column: "bar" }; - /** - * Aligns series data points by their x-axis categories, ensuring all series have data points - * for each unique category. Missing values are filled with zeros. - * Categories are ordered by their name. - * - * @example - * // Input series: - * const series = [ - * { name: "A", data: [{x: "X2", y: 10}, {x: "X3", y: 30}] }, - * { name: "B", data: [{x: "X1", y: 25}, {x: "X2", y: 20}] } - * ]; - * - * // Output after align_categories (orderedCategories will be ["X1","X2", "X3"]): - * // [ - * // { name: "A", data: [{x: "X1", y: 0}, {x: "X2", y: 10}, {x: "X3", y: 30}] }, - * // { name: "B", data: [{x: "X1", y: 25}, {x: "X2", y: 20}, {x: "X3", y: 0}] } - * // ] - * - * @param {(Series[string])[]} series - Array of series objects, each containing name and data points - * @returns {Series[string][]} Aligned series with consistent categories across all series - */ - function align_categories(series) { - const categoriesSet = new Set(); - const pointers = series.map((_) => 0); // Index of current data point in each series - const x_at = (series_idx) => - series[series_idx].data[pointers[series_idx]].x; - const series_idxs = series.flatMap((s, i) => (s.data.length ? i : [])); - while (series_idxs.length > 0) { - let idx_of_xmin = series_idxs[0]; - for (const series_idx of series_idxs) { - if (x_at(series_idx) < x_at(idx_of_xmin)) idx_of_xmin = series_idx; - } + /** @typedef { { [name:string]: {data:{x:number|string|Date,y:number}[], name:string} } } Series */ - const new_category = x_at(idx_of_xmin); - if (!categoriesSet.has(new_category)) categoriesSet.add(new_category); - pointers[idx_of_xmin]++; - if (pointers[idx_of_xmin] >= series[idx_of_xmin].data.length) { - series_idxs.splice(series_idxs.indexOf(idx_of_xmin), 1); - } - } - // Create a map of category -> value for each series and rebuild - return series.map((s) => { - const valueMap = new Map(s.data.map((point) => [point.x, point.y])); - return { - name: s.name, - data: Array.from(categoriesSet, (category) => ({ - x: category, - y: valueMap.get(category) || 0, - })), - }; - }); - } + const { align_series } = chart_series; /** @param {HTMLElement} c */ function build_sqlpage_chart(c) { @@ -98,6 +51,10 @@ sqlpage_chart = (() => { const chartContainer = c.querySelector(".chart"); chartContainer.innerHTML = ""; const is_timeseries = !!data.time; + const chart_type = + APEXCHARTS_TYPE_ALIASES[data.type] || data.type || "line"; + const is_stacked = + !!data.stacked && STACKABLE_CHART_TYPES.includes(chart_type); /** @type { Series } */ const series_map = {}; for (const [name, old_x, old_y, z] of data.points) { @@ -106,7 +63,7 @@ sqlpage_chart = (() => { let y = old_y; if (is_timeseries) { if (typeof x === "number") x = new Date(x * 1000); - else if (data.type === "rangeBar" && Array.isArray(y)) + else if (chart_type === "rangeBar" && Array.isArray(y)) y = y.map((y) => new Date(y).getTime()); else x = new Date(x); } @@ -128,13 +85,15 @@ sqlpage_chart = (() => { let labels; const categories = series.length > 0 && typeof series[0].data[0].x === "string"; - if (data.type === "pie") { + const y_is_a_range = chart_type === "rangeBar"; + const apexcharts_pairs_points_by_index = + is_stacked || (categories && !y_is_a_range); + if (chart_type === "pie") { labels = data.points.map(([name, x, _y]) => x || name); series = data.points.map(([_name, _x, y]) => Number.parseFloat(y)); - } else if (categories && data.type === "bar" && series.length > 1) - series = align_categories(series); + } else if (series.length > 1 && apexcharts_pairs_points_by_index) + series = align_series(series, is_stacked ? 0 : null); - const chart_type = data.type || "line"; const options = { chart: { type: chart_type, @@ -142,7 +101,7 @@ sqlpage_chart = (() => { background: "transparent", parentHeightOffset: 0, height: chartContainer.style.height, - stacked: !!data.stacked, + stacked: is_stacked, toolbar: { show: !!data.toolbar, }, @@ -167,15 +126,15 @@ sqlpage_chart = (() => { color: "var(--tblr-primary-bg-subtle)", }, formatter: - data.type === "rangeBar" + chart_type === "rangeBar" ? (_val, { seriesIndex, w }) => w.config.series[seriesIndex].name - : data.type === "pie" + : chart_type === "pie" ? (value, { seriesIndex, w }) => `${w.config.labels[seriesIndex]}: ${value.toFixed()}%` : (value) => value?.toLocaleString?.() || value, }, fill: { - type: data.type === "area" ? "gradient" : "solid", + type: chart_type === "area" ? "gradient" : "solid", }, stroke: { width: @@ -225,13 +184,13 @@ sqlpage_chart = (() => { tooltip: { fillSeriesColor: false, custom: - data.type === "bubble" || data.type === "scatter" + chart_type === "bubble" || chart_type === "scatter" ? bubbleTooltip : undefined, y: { formatter: (value) => { if (value == null) return ""; - if (is_timeseries && data.type === "rangeBar") { + if (is_timeseries && chart_type === "rangeBar") { const d = new Date(value); if (d.getHours() === 0 && d.getMinutes() === 0) return d.toLocaleDateString(); @@ -246,7 +205,7 @@ sqlpage_chart = (() => { }, plotOptions: { bar: { - horizontal: !!data.horizontal || data.type === "rangeBar", + horizontal: !!data.horizontal || chart_type === "rangeBar", borderRadius: 5, }, bubble: { minBubbleRadius: 5 }, @@ -257,7 +216,6 @@ sqlpage_chart = (() => { if (labels) options.labels = labels; // tickamount is the number of intervals, not the number of ticks if (data.xticks) options.xaxis.tickAmount = data.xticks; - console.log("Rendering chart", options); const chart = new ApexCharts(chartContainer, options); chart.render(); if (window.charts) window.charts.push(chart); diff --git a/sqlpage/chart_series.js b/sqlpage/chart_series.js new file mode 100644 index 00000000..535a19f6 --- /dev/null +++ b/sqlpage/chart_series.js @@ -0,0 +1,58 @@ +const chart_series = (() => { + /** @typedef {number|string|Date} XValue */ + /** @typedef { {name:string, data:{x:XValue,y:number|null,z?:number}[]} } ChartSeries */ + + /** + * @param {XValue} x + * @returns {number|string} + */ + const x_map_key = (x) => (x instanceof Date ? x.getTime() : x); + + /** + * The x values of every series, in the order the series wrote them when they + * agree, and in ascending order where they diverge. + * + * @param {ChartSeries[]} series + * @returns {XValue[]} + */ + function merge_x_values(series) { + const queues = series.map(({ data }) => data.map(({ x }) => x)); + const merged = new Map(); + + while (queues.some((queue) => queue.length > 0)) { + const lowest = queues + .filter((queue) => queue.length > 0) + .reduce((a, b) => (b[0] < a[0] ? b : a)); + const x = lowest.shift(); + merged.set(x_map_key(x), x); + } + return [...merged.values()]; + } + + /** + * Gives every series a point at every x any of them has, so that charts that + * pair their points by index line them up by x value instead. + * + * @param {ChartSeries[]} series + * @param {number|null} y_when_missing what a series with no value at an x is + * worth there: zero to add nothing to a stack, null to leave a gap. + * @returns {ChartSeries[]} + */ + function align_series(series, y_when_missing) { + const all_x = merge_x_values(series); + return series.map(({ name, data }) => { + const points_by_x = new Map(data.map((p) => [x_map_key(p.x), p])); + return { + name, + data: all_x.map((x) => { + const point = points_by_x.get(x_map_key(x)); + return point?.y == null ? { x, y: y_when_missing } : point; + }), + }; + }); + } + + return { align_series, merge_x_values }; +})(); + +if (typeof module !== "undefined") module.exports = chart_series; diff --git a/sqlpage/sqlpage.js b/sqlpage/sqlpage.js index 6e158c07..6f2b0d43 100644 --- a/sqlpage/sqlpage.js +++ b/sqlpage/sqlpage.js @@ -182,12 +182,19 @@ function sqlpage_map() { onLeafletLoad(); } /** - * * @param {string|undefined} coords * @returns {[number, number] | undefined} */ function parseCoords(coords) { - return coords?.split(",", 2).map((c) => Number.parseFloat(c)); + if (!coords) return undefined; + const parsed = coords.split(",", 2).map((c) => Number.parseFloat(c)); + if (parsed.length !== 2 || !parsed.every(Number.isFinite)) { + console.error( + `Invalid map coordinates: ${JSON.stringify(coords)}. Expected a "latitude,longitude" pair of numbers.`, + ); + return undefined; + } + return [parsed[0], parsed[1]]; } function onLeafletLoad() { is_leaflet_loaded = true; @@ -230,6 +237,7 @@ function sqlpage_map() { const marker = dataset.coords ? createMarker(marker_elem, options) : createGeoJSONMarker(marker_elem, options); + if (!marker) return; marker.addTo(map); map._sqlpage_markers.push(marker); if (marker_elem.textContent.trim()) marker.bindPopup(marker_elem); @@ -241,6 +249,7 @@ function sqlpage_map() { } function createMarker(marker_elem, options) { const coords = parseCoords(marker_elem.dataset.coords); + if (!coords) return undefined; const icon_obj = marker_elem.getElementsByClassName("mapicon")[0]; if (icon_obj) { const size = diff --git a/tests/end-to-end/chart-component.spec.ts b/tests/end-to-end/chart-component.spec.ts new file mode 100644 index 00000000..9f34cbaa --- /dev/null +++ b/tests/end-to-end/chart-component.spec.ts @@ -0,0 +1,282 @@ +import { expect, type Page, test } from "@playwright/test"; + +const BASE = process.env.SQLPAGE_TEST_BASE ?? "http://localhost:8080/"; + +type ChartPoint = { x: string | number | Date; y: number | null }; + +declare global { + interface Window { + charts?: { + w: { + config: { + chart: { type: string; stacked: boolean }; + series: { name: string; data: ChartPoint[] }[]; + }; + }; + }[]; + } + function sqlpage_chart(): void; +} + +type Row = [series: string, x: unknown, y: unknown, z?: unknown]; + +const A_DAY_OF_WORK: Row[] = [ + ["Coding", "Mon", 6], + ["Coding", "Tue", 4], + ["Coding", "Wed", 7], +]; + +const TASKS_OVER_TIME: Row[] = [ + ["Design", "Alice", ["2024-03-01", "2024-03-05"]], + ["Build", "Bob", ["2024-03-04", "2024-03-09"]], +]; + +const CPU_AT_EVERY_MINUTE: Row[] = [ + ["CPU", "2024-01-01T00:00:00Z", 10], + ["CPU", "2024-01-01T00:01:00Z", 20], + ["CPU", "2024-01-01T00:02:00Z", 30], + ["CPU", "2024-01-01T00:03:00Z", 40], +]; + +const GPU_ONLY_ONCE_THE_RENDER_STARTED: Row[] = [ + ["GPU", "2024-01-01T00:01:00Z", 50], + ["GPU", "2024-01-01T00:02:00Z", 50], + ["GPU", "2024-01-01T00:03:00Z", 50], +]; + +const A_IN_EVERY_QUARTER: Row[] = [ + ["A", "Q1", 1], + ["A", "Q2", 2], + ["A", "Q3", 3], +]; + +const B_MISSING_THE_FIRST_QUARTER: Row[] = [ + ["B", "Q2", 20], + ["B", "Q3", 30], +]; + +async function renderChart( + page: Page, + chart: Record, + rows: Row[], +) { + return page.evaluate( + ({ chart, rows }) => { + document.getElementById("test-chart")?.remove(); + const container = document.createElement("div"); + container.id = "test-chart"; + container.setAttribute("data-pre-init", "chart"); + const payload = JSON.stringify({ + colors: [], + marker: 4, + ...chart, + points: rows, + }); + container.innerHTML = `
`; + document.body.appendChild(container); + + const failures: string[] = []; + const reportError = console.error; + console.error = (...args) => failures.push(args.map(String).join(" ")); + const before = window.charts?.length ?? 0; + sqlpage_chart(); + console.error = reportError; + + const rendered = window.charts?.[before]; + const series = (rendered?.w.config.series ?? []).map((s) => ({ + name: s.name, + points: s.data.map((p) => [ + p.x instanceof Date ? p.x.toISOString() : p.x, + p.y, + ]), + })); + const drawnPerSeries = series.map(({ name }) => { + const markers = [ + ...container.querySelectorAll( + `.apexcharts-series[seriesName='${name}'] .apexcharts-series-markers > .apexcharts-marker`, + ), + ].map((m) => m.getBBox()); + return { + name, + lefts: markers.map((b) => Math.round(b.x)), + heights: markers.map((b) => Math.round(b.y)), + }; + }); + const shapes = [ + ...container.querySelectorAll( + ".apexcharts-bar-area, .apexcharts-rangebar-area", + ), + ].map((shape) => { + const { x, y, width, height } = shape.getBBox(); + return { x, y, width, height }; + }); + + return { + failures, + type: rendered?.w.config.chart.type ?? null, + stacked: rendered?.w.config.chart.stacked ?? null, + series, + drawnPerSeries, + shapes, + }; + }, + { chart, rows }, + ); +} + +test.beforeEach(async ({ page }) => { + await page.goto(`${BASE}/documentation.sql?component=chart#component`); + await page.waitForSelector(".apexcharts-canvas"); +}); + +test("draws a column chart as a vertical bar chart", async ({ page }) => { + const chart = await renderChart(page, { type: "column" }, A_DAY_OF_WORK); + + expect(chart.failures).toEqual([]); + expect(chart.shapes).toHaveLength(3); + expect(chart.type).toBe("bar"); + + expect(new Set(chart.shapes.map((s) => s.x)).size).toBe(3); + expect(new Set(chart.shapes.map((s) => s.height)).size).toBe(3); +}); + +test("gives a stacked series a zero at every x it did not measure", async ({ + page, +}) => { + const chart = await renderChart( + page, + { type: "area", stacked: true, time: true }, + [...CPU_AT_EVERY_MINUTE, ...GPU_ONLY_ONCE_THE_RENDER_STARTED], + ); + + expect(chart.failures).toEqual([]); + expect(chart.series.map((s) => s.name)).toEqual(["CPU", "GPU"]); + expect(chart.series[1].points).toEqual([ + ["2024-01-01T00:00:00.000Z", 0], + ["2024-01-01T00:01:00.000Z", 50], + ["2024-01-01T00:02:00.000Z", 50], + ["2024-01-01T00:03:00.000Z", 50], + ]); +}); + +test("stacks a series above the one it shares an x with", async ({ page }) => { + const chart = await renderChart( + page, + { type: "area", stacked: true, time: true }, + [...CPU_AT_EVERY_MINUTE, ...GPU_ONLY_ONCE_THE_RENDER_STARTED], + ); + const [cpu, gpu] = chart.drawnPerSeries; + + expect(gpu.heights).toHaveLength(4); + expect(gpu.heights[0]).toBe(cpu.heights[0]); + expect(gpu.heights[1]).toBeLessThan(cpu.heights[1]); +}); + +test("stacks a bar series on the categories it skipped", async ({ page }) => { + const chart = await renderChart(page, { type: "bar", stacked: true }, [ + ...A_IN_EVERY_QUARTER, + ...B_MISSING_THE_FIRST_QUARTER, + ]); + + expect(chart.failures).toEqual([]); + expect(chart.series[1].points).toEqual([ + ["Q1", 0], + ["Q2", 20], + ["Q3", 30], + ]); +}); + +test("lines an unstacked series up with the categories it skipped", async ({ + page, +}) => { + const chart = await renderChart(page, { type: "line" }, [ + ...A_IN_EVERY_QUARTER, + ...B_MISSING_THE_FIRST_QUARTER, + ]); + + expect(chart.failures).toEqual([]); + expect(chart.series[1].points).toEqual([ + ["Q1", null], + ["Q2", 20], + ["Q3", 30], + ]); +}); + +test("draws nothing where an unstacked series has no value", async ({ + page, +}) => { + const chart = await renderChart(page, { type: "line" }, [ + ...A_IN_EVERY_QUARTER, + ...B_MISSING_THE_FIRST_QUARTER, + ]); + const [a, b] = chart.drawnPerSeries; + + expect(a.lefts).toHaveLength(3); + expect(b.lefts).toEqual(a.lefts.slice(1)); +}); + +test("keeps a measured zero apart from a missing value", async ({ page }) => { + const chart = await renderChart(page, { type: "line" }, [ + ...A_IN_EVERY_QUARTER, + ["B", "Q2", 0], + ["B", "Q3", 30], + ]); + const [a, b] = chart.drawnPerSeries; + + expect(chart.series[1].points).toEqual([ + ["Q1", null], + ["Q2", 0], + ["Q3", 30], + ]); + expect(b.lefts).toEqual(a.lefts.slice(1)); +}); + +for (const type of ["area", "scatter", "heatmap"]) { + test(`lines up the series of a ${type} chart on a category axis`, async ({ + page, + }) => { + const chart = await renderChart(page, { type }, [ + ...A_IN_EVERY_QUARTER, + ...B_MISSING_THE_FIRST_QUARTER, + ]); + + expect(chart.failures).toEqual([]); + expect(chart.series[1].points.map((p) => p[0])).toEqual(["Q1", "Q2", "Q3"]); + }); +} + +test("keeps the bubble size of the points it lined up", async ({ page }) => { + const chart = await renderChart(page, { type: "bubble" }, [ + ["A", "Q1", 1, 30], + ["A", "Q2", 2, 30], + ["B", "Q2", 5, 70], + ]); + + expect(chart.failures).toEqual([]); + expect(chart.series[1].points).toEqual([ + ["Q1", null], + ["Q2", 5], + ]); +}); + +test("leaves a rangeBar chart on a category axis alone", async ({ page }) => { + const chart = await renderChart(page, { type: "rangeBar", time: true }, [ + ["Design", "Alice", ["2024-03-01", "2024-03-05"]], + ["Build", "Bob", ["2024-03-04", "2024-03-09"]], + ]); + + expect(chart.failures).toEqual([]); + expect(chart.shapes).toHaveLength(2); +}); + +test("draws a rangeBar chart that asks to be stacked", async ({ page }) => { + const chart = await renderChart( + page, + { type: "rangeBar", stacked: true, time: true }, + TASKS_OVER_TIME, + ); + + expect(chart.failures).toEqual([]); + expect(chart.shapes).toHaveLength(2); + expect(chart.stacked).toBe(false); +}); diff --git a/tests/end-to-end/map-component.spec.ts b/tests/end-to-end/map-component.spec.ts new file mode 100644 index 00000000..58bb6681 --- /dev/null +++ b/tests/end-to-end/map-component.spec.ts @@ -0,0 +1,116 @@ +import { expect, type Page, test } from "@playwright/test"; + +const BASE = process.env.SQLPAGE_TEST_BASE ?? "http://localhost:8080/"; + +declare global { + function sqlpage_map(): void; +} + +type Marker = { coords?: string; title: string }; + +const PARIS = "48.85,2.35"; +const PARIS_WITHOUT_ITS_LONGITUDE = "48.85,"; +const NOT_COORDINATES = "somewhere nice"; + +async function renderMap( + page: Page, + center: string | null, + markers: Marker[] = [], +) { + return page.evaluate( + async ({ center, markers }) => { + document.getElementById("test-map")?.remove(); + const container = document.createElement("div"); + container.id = "test-map"; + container.className = "leaflet"; + container.style.height = "200px"; + container.dataset.zoom = "5"; + container.dataset.max_zoom = "18"; + if (center !== null) container.dataset.center = center; + container.innerHTML = markers + .map( + (m) => + `

${m.title}

`, + ) + .join(""); + container.dataset.preInit = "map"; + document.body.appendChild(container); + + const errors: string[] = []; + const record = (e: ErrorEvent) => errors.push(e.message); + window.addEventListener("error", record); + + const logged: string[] = []; + const console_error = console.error; + console.error = (...args) => logged.push(args.join(" ")); + + sqlpage_map(); + await new Promise((resolve) => setTimeout(resolve, 500)); + + console.error = console_error; + window.removeEventListener("error", record); + + return { + errors, + logged, + markers: container.querySelectorAll(".leaflet-marker-icon").length, + initialized: !!container.querySelector(".leaflet-map-pane"), + }; + }, + { center, markers }, + ); +} + +test.beforeEach(async ({ page }) => { + await page.goto(`${BASE}/documentation.sql?component=map#component`); + await page.waitForFunction(() => "L" in window); +}); + +test("centers the map on a pair of coordinates", async ({ page }) => { + const map = await renderMap(page, PARIS); + + expect(map.errors).toEqual([]); + expect(map.logged).toEqual([]); + expect(map.initialized).toBe(true); +}); + +test("reports a center whose longitude is missing", async ({ page }) => { + const map = await renderMap(page, PARIS_WITHOUT_ITS_LONGITUDE); + + expect(map.errors).toEqual([]); + expect(map.logged).toEqual([ + expect.stringContaining(PARIS_WITHOUT_ITS_LONGITUDE), + ]); + expect(map.initialized).toBe(true); +}); + +test("reports a center that is not a pair of numbers", async ({ page }) => { + const map = await renderMap(page, NOT_COORDINATES); + + expect(map.errors).toEqual([]); + expect(map.logged).toEqual([expect.stringContaining(NOT_COORDINATES)]); + expect(map.initialized).toBe(true); +}); + +test("draws a marker at a pair of coordinates", async ({ page }) => { + const map = await renderMap(page, PARIS, [{ coords: PARIS, title: "Paris" }]); + + expect(map.errors).toEqual([]); + expect(map.logged).toEqual([]); + expect(map.markers).toBe(1); +}); + +test("reports a marker whose longitude is missing, keeping the others", async ({ + page, +}) => { + const map = await renderMap(page, PARIS, [ + { coords: PARIS_WITHOUT_ITS_LONGITUDE, title: "Half of Paris" }, + { coords: PARIS, title: "Paris" }, + ]); + + expect(map.errors).toEqual([]); + expect(map.logged).toEqual([ + expect.stringContaining(PARIS_WITHOUT_ITS_LONGITUDE), + ]); + expect(map.markers).toBe(1); +}); diff --git a/tests/end-to-end/official-site.spec.ts b/tests/end-to-end/official-site.spec.ts index d3a14de3..c81117c3 100644 --- a/tests/end-to-end/official-site.spec.ts +++ b/tests/end-to-end/official-site.spec.ts @@ -1,4 +1,4 @@ -import { expect, type Page, test } from "@playwright/test"; +import { expect, type Locator, type Page, test } from "@playwright/test"; const BASE = process.env.SQLPAGE_TEST_BASE ?? "http://localhost:8080/"; @@ -35,6 +35,49 @@ test("chart supports hiding legend", async ({ page }) => { await expect(expensesChart.locator(".apexcharts-legend")).toBeHidden(); }); +const DATA_POINT_MARKERS = ".apexcharts-series-markers > .apexcharts-marker"; + +const chartCard = (page: Page, title: string) => + page.locator(".card", { has: page.getByRole("heading", { name: title }) }); + +const drawnPoints = (card: Locator, series: string) => + card + .locator(`.apexcharts-series[seriesName='${series}'] ${DATA_POINT_MARKERS}`) + .evaluateAll((markers) => + markers.map((m) => ({ + x: m.getAttribute("cx"), + y: m.getAttribute("cy"), + })), + ); + +test("stacked chart draws every series at every x of the chart", async ({ + page, +}) => { + await page.goto(`${BASE}/documentation.sql?component=chart#component`); + const powerChart = chartCard(page, "Power draw"); + await expect(powerChart.locator(".apexcharts-canvas")).toBeVisible(); + + const cpu = await drawnPoints(powerChart, "CPU"); + const gpu = await drawnPoints(powerChart, "GPU"); + + expect(cpu).toHaveLength(4); + expect(gpu.map((p) => p.x)).toEqual(cpu.map((p) => p.x)); +}); + +test("stacked chart raises a series only where it has a value", async ({ + page, +}) => { + await page.goto(`${BASE}/documentation.sql?component=chart#component`); + const powerChart = chartCard(page, "Power draw"); + await expect(powerChart.locator(".apexcharts-canvas")).toBeVisible(); + + const cpu = await drawnPoints(powerChart, "CPU"); + const gpu = await drawnPoints(powerChart, "GPU"); + + expect([gpu[0], gpu[3]]).toEqual([cpu[0], cpu[3]]); + expect(Number(gpu[1].y)).toBeLessThan(Number(cpu[1].y)); +}); + test("map", async ({ page }) => { await page.goto(`${BASE}/documentation.sql?component=map#component`); await expect(page.getByText("Loading...")).not.toBeVisible(); diff --git a/tests/js/chart_series.spec.ts b/tests/js/chart_series.spec.ts new file mode 100644 index 00000000..15bef88a --- /dev/null +++ b/tests/js/chart_series.spec.ts @@ -0,0 +1,191 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import chart_series from "../../sqlpage/chart_series.js"; + +const { align_series, merge_x_values } = chart_series; + +const ADDS_NOTHING_TO_THE_STACK = 0; +const LEAVES_A_GAP = null; + +type XValue = number | string | Date; +type Point = { x: XValue; y: number | null; z?: number }; +type Series = { name: string; data: Point[] }; + +const series = (name: string, ...data: Point[]): Series => ({ name, data }); + +test("merge_x_values keeps the order series already agree on", () => { + const merged = merge_x_values([ + series("a", { x: "Q1", y: 1 }, { x: "Q2", y: 2 }, { x: "Q3", y: 3 }), + series("b", { x: "Q1", y: 4 }, { x: "Q2", y: 5 }, { x: "Q3", y: 6 }), + ]); + + assert.deepEqual(merged, ["Q1", "Q2", "Q3"]); +}); + +test("merge_x_values orders by name what series do not share", () => { + const merged = merge_x_values([ + series("a", { x: "Q1", y: 1 }, { x: "Q3", y: 3 }), + series("b", { x: "Q2", y: 2 }), + ]); + + assert.deepEqual(merged, ["Q1", "Q2", "Q3"]); +}); + +test("merge_x_values compares numbers as numbers, not as text", () => { + const merged = merge_x_values([ + series("a", { x: 2, y: 1 }, { x: 10, y: 1 }), + series("b", { x: 9, y: 1 }), + ]); + + assert.deepEqual(merged, [2, 9, 10]); +}); + +test("merge_x_values matches equal dates written as different objects", () => { + const merged = merge_x_values([ + series("a", { x: new Date("2024-03-01"), y: 1 }), + series("b", { x: new Date("2024-03-01"), y: 2 }), + ]); + + assert.equal(merged.length, 1); +}); + +test("merge_x_values ignores series with no points", () => { + const merged = merge_x_values([series("empty"), series("a", { x: 1, y: 1 })]); + + assert.deepEqual(merged, [1]); +}); + +test("align_series gives every series a point at every x", () => { + const [a, b] = align_series( + [ + series("a", { x: "Q1", y: 1 }, { x: "Q2", y: 2 }), + series("b", { x: "Q2", y: 3 }), + ], + LEAVES_A_GAP, + ); + + assert.deepEqual( + a.data.map((p) => p.x), + ["Q1", "Q2"], + ); + assert.deepEqual( + b.data.map((p) => p.x), + ["Q1", "Q2"], + ); +}); + +test("align_series counts a stacked series with no value as zero", () => { + const [, b] = align_series( + [ + series("a", { x: "Q1", y: 1 }, { x: "Q2", y: 2 }), + series("b", { x: "Q2", y: 3 }), + ], + ADDS_NOTHING_TO_THE_STACK, + ); + + assert.deepEqual(b.data, [ + { x: "Q1", y: 0 }, + { x: "Q2", y: 3 }, + ]); +}); + +test("align_series leaves a gap where an unstacked series has no value", () => { + const [, b] = align_series( + [ + series("a", { x: "Q1", y: 1 }, { x: "Q2", y: 2 }), + series("b", { x: "Q2", y: 3 }), + ], + LEAVES_A_GAP, + ); + + assert.deepEqual(b.data, [ + { x: "Q1", y: null }, + { x: "Q2", y: 3 }, + ]); +}); + +test("align_series keeps a measured zero apart from a missing value", () => { + const [, b] = align_series( + [ + series("a", { x: "Q1", y: 1 }, { x: "Q2", y: 2 }), + series("b", { x: "Q2", y: 0 }), + ], + LEAVES_A_GAP, + ); + + assert.deepEqual(b.data, [ + { x: "Q1", y: null }, + { x: "Q2", y: 0 }, + ]); +}); + +test("align_series counts a null value as missing", () => { + const [, b] = align_series( + [series("a", { x: "Q1", y: 1 }), series("b", { x: "Q1", y: null })], + ADDS_NOTHING_TO_THE_STACK, + ); + + assert.deepEqual(b.data, [{ x: "Q1", y: 0 }]); +}); + +test("align_series keeps the z of points it did not fill in", () => { + const [a] = align_series( + [series("a", { x: "Q1", y: 1, z: 42 }), series("b", { x: "Q2", y: 2 })], + LEAVES_A_GAP, + ); + + assert.equal(a.data[0].z, 42); +}); + +test("align_series matches dates by value rather than by identity", () => { + const [a, b] = align_series( + [ + series("a", { x: new Date("2024-03-01"), y: 1 }), + series("b", { x: new Date("2024-03-01"), y: 2 }), + ], + ADDS_NOTHING_TO_THE_STACK, + ); + + assert.equal(a.data.length, 1); + assert.equal(b.data.length, 1); + assert.equal(b.data[0].y, 2); +}); + +test("align_series leaves a single series alone", () => { + const [only] = align_series( + [series("a", { x: "Q2", y: 1 }, { x: "Q1", y: 2 })], + LEAVES_A_GAP, + ); + + assert.deepEqual(only.data, [ + { x: "Q2", y: 1 }, + { x: "Q1", y: 2 }, + ]); +}); + +test("align_series does not mutate the series it is given", () => { + const given = [ + series("a", { x: "Q1", y: 1 }), + series("b", { x: "Q2", y: 2 }), + ]; + const before = JSON.stringify(given); + + align_series(given, LEAVES_A_GAP); + + assert.equal(JSON.stringify(given), before); +}); + +test("align_series keeps the last of duplicated x values", () => { + const [a] = align_series( + [ + series("a", { x: "Q1", y: 1 }, { x: "Q1", y: 9 }), + series("b", { x: "Q2", y: 2 }), + ], + ADDS_NOTHING_TO_THE_STACK, + ); + + assert.deepEqual(a.data, [ + { x: "Q1", y: 9 }, + { x: "Q2", y: 0 }, + ]); +}); diff --git a/tests/js/package.json b/tests/js/package.json new file mode 100644 index 00000000..3dbc1ca5 --- /dev/null +++ b/tests/js/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +}