diff --git a/frontend/src/apexcharts.ts b/frontend/src/apexcharts.ts index 8a2e5d37..70fc83c8 100644 --- a/frontend/src/apexcharts.ts +++ b/frontend/src/apexcharts.ts @@ -104,7 +104,7 @@ const axisTooltip = const formatted = Array.isArray(measured) ? measured.map(format).join(" - ") : format(measured); - if (axis === "y" && point.link) + if (axis === "y" && point?.link) valueSpan.innerHTML = linkTooltipValue(formatted, point.link); else valueSpan.innerText = formatted; axisValue.appendChild(valueSpan); @@ -141,7 +141,7 @@ const sqlpage_chart = (() => { ["gray", "#495057", "#adb5bd"], ["black", "#000000", "#000000"], ["white", "#ffffff", "#f8f9fa"], - ]; + ] as const; const colorNames = new Map( tblrColors.flatMap(([name, dark, light]): [string, string][] => [ [name, dark], @@ -200,8 +200,10 @@ const sqlpage_chart = (() => { function build_sqlpage_chart(c: HTMLElement) { const [data_element] = c.getElementsByTagName("data"); + const chartContainer = c.querySelector(".chart"); + if (!data_element || !chartContainer) + throw new Error("Chart component is missing its data or container"); const data = read_chart_data(data_element.textContent); - const chartContainer = c.querySelector(".chart") as HTMLElement; chartContainer.innerHTML = ""; const is_timeseries = data.time; const chart_type = @@ -260,7 +262,10 @@ const sqlpage_chart = (() => { : aligned_series; const colors = is_pie ? points.map( - ({ color }, i) => named_color(color) || palette[i % palette.length], + ({ color }, i) => + named_color(color) ?? + palette[i % palette.length] ?? + tblrColors[0][isDarkTheme ? 1 : 2], ) : palette; diff --git a/frontend/src/chart_series.ts b/frontend/src/chart_series.ts index 52e6d7fb..333484f6 100644 --- a/frontend/src/chart_series.ts +++ b/frontend/src/chart_series.ts @@ -52,14 +52,21 @@ export function xaxis_type_for( * ascending order where they diverge */ export function merged_x_values(series: ChartSeries[]): XValue[] { - const unread = series.map(({ data }) => data.map(({ x }) => x)); + const unread = series.map(({ data }) => { + const iterator = data.values(); + return { iterator, next: iterator.next() }; + }); const merged = new Map(); - while (unread.some((xs) => xs.length > 0)) { - const with_lowest_x = unread - .filter((xs) => xs.length > 0) - .reduce((a, b) => (is_lower(b[0], a[0]) ? b : a)); - const x = with_lowest_x.shift() as XValue; - merged.set(x_key(x), x); + while (true) { + let lowest: { stream: (typeof unread)[number]; x: XValue } | undefined; + for (const stream of unread) { + if (stream.next.done) continue; + const x = stream.next.value.x; + if (!lowest || is_lower(x, lowest.x)) lowest = { stream, x }; + } + if (!lowest) break; + merged.set(x_key(lowest.x), lowest.x); + lowest.stream.next = lowest.stream.iterator.next(); } return [...merged.values()]; } diff --git a/frontend/src/sqlpage.ts b/frontend/src/sqlpage.ts index efd31575..8570c3a3 100644 --- a/frontend/src/sqlpage.ts +++ b/frontend/src/sqlpage.ts @@ -103,10 +103,9 @@ function apply_number_formatting(table_el: HTMLElement) { for (const tr_el of table_el.querySelectorAll("tbody tr, tfoot tr")) { const cells = tr_el.getElementsByTagName("td"); - for (let idx = 0; idx < cells.length; idx++) { + for (const [idx, cell_el] of [...cells].entries()) { const column_type = col_types[idx]; const is_raw_number = col_rawnums[idx]; - const cell_el = cells[idx]; const text = cell_el.textContent; if (column_type === "number" && !is_raw_number && text) { @@ -166,6 +165,8 @@ function setup_sort_behavior( items.sort((a, b) => { const a_key = a.sort_keys[button_index]; const b_key = b.sort_keys[button_index]; + if (!a_key || !b_key) + throw new Error("Table row is missing the selected column sort key"); return ( multiplier * (Number.isNaN(a_key.num) || Number.isNaN(b_key.num) @@ -243,14 +244,21 @@ function sqlpage_map(root: InitRoot) { coords: string | undefined, ): Leaflet.LatLngTuple | undefined { if (!coords) return undefined; - const parsed = coords.split(",", 2).map((c) => Number.parseFloat(c)); - if (parsed.length !== 2 || !parsed.every(Number.isFinite)) { + const [latitude, longitude] = coords + .split(",", 2) + .map((c) => Number.parseFloat(c)); + if ( + latitude === undefined || + longitude === undefined || + !Number.isFinite(latitude) || + !Number.isFinite(longitude) + ) { console.error( `Invalid map coordinates: ${JSON.stringify(coords)}. Expected a "latitude,longitude" pair of numbers.`, ); return undefined; } - return [parsed[0], parsed[1]]; + return [latitude, longitude]; } function onLeafletLoad(map_root: InitRoot) { is_leaflet_loaded = true; diff --git a/tests/end-to-end/fixtures/chart/test.ts b/tests/end-to-end/fixtures/chart/test.ts index 46635206..8038beb2 100644 --- a/tests/end-to-end/fixtures/chart/test.ts +++ b/tests/end-to-end/fixtures/chart/test.ts @@ -1,3 +1,4 @@ +import assert from "node:assert/strict"; import { type ConsoleMessage, expect, type Page, test } from "../../fixture.ts"; const MARKS = @@ -219,8 +220,11 @@ test("positions complete numeric bar series on an explicit numeric axis (#733)", expect(chart.axisLabels.map(({ text }) => Number(text))).toEqual(xs); expect(chart.dataLabels.map(Number)).toEqual([...xs, ...xs]); expect(chart.barGroups).toHaveLength(xs.length); - for (const [index, label] of chart.axisLabels.entries()) - expect(Math.abs(label.center - chart.barGroups[index])).toBeLessThan(1); + for (const [index, label] of chart.axisLabels.entries()) { + const barCenter = chart.barGroups[index]; + assert.ok(barCenter !== undefined, `Bar group ${index} was drawn`); + expect(Math.abs(label.center - barCenter)).toBeLessThan(1); + } }); test("keeps irregular numeric x values proportionately spaced", async ({ @@ -237,9 +241,9 @@ test("keeps irregular numeric x values proportionately spaced", async ({ "3.0", ]); expect(chart.axisLabels.map(({ text }) => text)).not.toContain("2"); - expect(chart.barGroups[2] - chart.barGroups[1]).toBeGreaterThan( - 5 * (chart.barGroups[1] - chart.barGroups[0]), - ); + const [first, second, third] = chart.barGroups; + assert.ok(first !== undefined && second !== undefined && third !== undefined); + expect(third - second).toBeGreaterThan(5 * (second - first)); }); test("keeps an explicit x interval count", async ({ page }) => { @@ -299,7 +303,7 @@ test("gives a stacked series a zero at every x it did not measure", async ({ expect(chart.failures).toEqual([]); expect(chart.series.map((s) => s.name)).toEqual(["CPU", "GPU"]); - expect(chart.series[1].points).toEqual([ + 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], @@ -310,10 +314,13 @@ test("gives a stacked series a zero at every x it did not measure", async ({ test("stacks a series above the one it shares an x with", async ({ page }) => { const chart = await renderChart(page, "stacked-time-series"); const [cpu, gpu] = chart.drawnPerSeries; + assert.ok(cpu && gpu, "Both series were drawn"); expect(gpu.heights).toHaveLength(4); expect(gpu.heights[0]).toBe(cpu.heights[0]); - expect(gpu.heights[1]).toBeLessThan(cpu.heights[1]); + const cpuHeight = cpu.heights[1]; + assert.ok(cpuHeight !== undefined, "CPU has a second point"); + expect(gpu.heights[1]).toBeLessThan(cpuHeight); }); test("keeps a lone series in the order the query returned it (#930)", async ({ @@ -322,7 +329,7 @@ test("keeps a lone series in the order the query returned it (#930)", async ({ const chart = await renderChart(page, "out-of-order"); expect(chart.failures).toEqual([]); - expect(chart.series[0].points).toEqual([ + expect(chart.series[0]?.points).toEqual([ ["Q3", 3], ["Q1", 1], ["Q2", 2], @@ -335,12 +342,12 @@ test("orders by name the categories two bar series do not share (#951)", async ( const chart = await renderChart(page, "disjoint-categories"); expect(chart.failures).toEqual([]); - expect(chart.series[0].points).toEqual([ + expect(chart.series[0]?.points).toEqual([ ["X1", 0], ["X2", 10], ["X3", 30], ]); - expect(chart.series[1].points).toEqual([ + expect(chart.series[1]?.points).toEqual([ ["X1", 25], ["X2", 20], ["X3", 0], @@ -353,7 +360,7 @@ test("leaves the points of a chart that does not stack alone", async ({ const chart = await renderChart(page, "unstacked-time-series"); expect(chart.failures).toEqual([]); - expect(chart.series[1].points).toEqual([ + expect(chart.series[1]?.points).toEqual([ ["2024-01-01T00:01:00.000Z", 50], ["2024-01-01T00:02:00.000Z", 50], ["2024-01-01T00:03:00.000Z", 50], @@ -364,7 +371,7 @@ test("stacks a bar series on the categories it skipped", async ({ page }) => { const chart = await renderChart(page, "stacked-categories"); expect(chart.failures).toEqual([]); - expect(chart.series[1].points).toEqual([ + expect(chart.series[1]?.points).toEqual([ ["Q1", 0], ["Q2", 20], ["Q3", 30], @@ -377,7 +384,7 @@ test("lines an unstacked series up with the categories it skipped", async ({ const chart = await renderChart(page, "line-categories"); expect(chart.failures).toEqual([]); - expect(chart.series[1].points).toEqual([ + expect(chart.series[1]?.points).toEqual([ ["Q1", null], ["Q2", 20], ["Q3", 30], @@ -389,6 +396,7 @@ test("draws nothing where an unstacked series has no value", async ({ }) => { const chart = await renderChart(page, "line-categories"); const [a, b] = chart.drawnPerSeries; + assert.ok(a && b, "Both series were drawn"); expect(a.lefts).toHaveLength(3); expect(b.lefts).toEqual(a.lefts.slice(1)); @@ -397,8 +405,9 @@ test("draws nothing where an unstacked series has no value", async ({ test("keeps a measured zero apart from a missing value", async ({ page }) => { const chart = await renderChart(page, "zero-and-missing"); const [a, b] = chart.drawnPerSeries; + assert.ok(a && b, "Both series were drawn"); - expect(chart.series[1].points).toEqual([ + expect(chart.series[1]?.points).toEqual([ ["Q1", null], ["Q2", 0], ["Q3", 30], @@ -413,7 +422,11 @@ for (const type of ["area", "scatter", "heatmap"]) { const chart = await renderChart(page, `${type}-categories`); expect(chart.failures).toEqual([]); - expect(chart.series[1].points.map((p) => p[0])).toEqual(["Q1", "Q2", "Q3"]); + expect(chart.series[1]?.points.map((p) => p[0])).toEqual([ + "Q1", + "Q2", + "Q3", + ]); }); } @@ -431,7 +444,7 @@ test("keeps the bubble size of the points it lined up", async ({ page }) => { const chart = await renderChart(page, "bubble-categories"); expect(chart.failures).toEqual([]); - expect(chart.series[1].points).toEqual([ + expect(chart.series[1]?.points).toEqual([ ["Q1", null], ["Q2", 5], ]); diff --git a/tests/end-to-end/official-site.spec.ts b/tests/end-to-end/official-site.spec.ts index 8b1a483b..b008787a 100644 --- a/tests/end-to-end/official-site.spec.ts +++ b/tests/end-to-end/official-site.spec.ts @@ -77,7 +77,7 @@ test("stacked chart raises a series only where it has a value", async ({ 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)); + expect(Number(gpu[1]?.y)).toBeLessThan(Number(cpu[1]?.y)); }); test("chart draws a yline as a line and a yline_end as a band", async ({ diff --git a/tests/js/chart_series.spec.ts b/tests/js/chart_series.spec.ts index ed56fd22..34df1572 100644 --- a/tests/js/chart_series.spec.ts +++ b/tests/js/chart_series.spec.ts @@ -91,6 +91,20 @@ test("merged_x_values ignores series that hold no points", () => { assert.deepEqual(merged, [1]); }); +test("merged_x_values returns no labels when every series is empty", () => { + assert.deepEqual(merged_x_values([]), []); + assert.deepEqual(merged_x_values([series("a"), series("b")]), []); +}); + +test("merged_x_values keeps null and zero as distinct labels", () => { + const merged = merged_x_values([ + series("a", { x: null, y: 1 }, { x: 1, y: 2 }), + series("b", { x: 0, y: 3 }), + ]); + + assert.deepEqual(merged, [null, 0, 1]); +}); + test("align_series gives every series a point at every x (#727)", () => { const [a, b] = align_series( [ @@ -100,6 +114,8 @@ test("align_series gives every series a point at every x (#727)", () => { ADDS_NOTHING_TO_THE_STACK, ); + assert.ok(a); + assert.ok(b); assert.deepEqual(xs(a), ["Q1", "Q2"]); assert.deepEqual(xs(b), ["Q1", "Q2"]); }); @@ -113,7 +129,7 @@ test("align_series counts an x a stacked series skipped as zero (#727)", () => { ADDS_NOTHING_TO_THE_STACK, ); - assert.deepEqual(b.data, [ + assert.deepEqual(b?.data, [ { x: "Q1", y: 0 }, { x: "Q2", y: 3 }, ]); @@ -128,7 +144,7 @@ test("align_series leaves a gap where an unstacked series has no value", () => { LEAVES_A_GAP, ); - assert.deepEqual(b.data, [ + assert.deepEqual(b?.data, [ { x: "Q1", y: null }, { x: "Q2", y: 3 }, ]); @@ -143,7 +159,7 @@ test("align_series keeps a measured zero apart from a missing value", () => { LEAVES_A_GAP, ); - assert.deepEqual(b.data, [ + assert.deepEqual(b?.data, [ { x: "Q1", y: null }, { x: "Q2", y: 0 }, ]); @@ -155,7 +171,7 @@ test("align_series counts a null value as missing", () => { ADDS_NOTHING_TO_THE_STACK, ); - assert.deepEqual(b.data, [{ x: "Q1", y: 0 }]); + assert.deepEqual(b?.data, [{ x: "Q1", y: 0 }]); }); test("align_series keeps a blank value the series wrote", () => { @@ -164,7 +180,7 @@ test("align_series keeps a blank value the series wrote", () => { ADDS_NOTHING_TO_THE_STACK, ); - assert.deepEqual(b.data, [{ x: "Q1", y: "" }]); + assert.deepEqual(b?.data, [{ x: "Q1", y: "" }]); }); test("align_series keeps a value the series wrote as text", () => { @@ -173,7 +189,7 @@ test("align_series keeps a value the series wrote as text", () => { ADDS_NOTHING_TO_THE_STACK, ); - assert.deepEqual(b.data, [{ x: "Q1", y: "7" }]); + assert.deepEqual(b?.data, [{ x: "Q1", y: "7" }]); }); test("align_series keeps the third dimension of points it did not fill in", () => { @@ -182,7 +198,7 @@ test("align_series keeps the third dimension of points it did not fill in", () = LEAVES_A_GAP, ); - assert.equal(a.data[0].z, 42); + assert.equal(a?.data[0]?.z, 42); }); test("align_series keeps the color of a point on the x it belongs to", () => { @@ -195,7 +211,7 @@ test("align_series keeps the color of a point on the x it belongs to", () => { ); assert.deepEqual( - b.data.map((point) => point.fillColor), + b?.data.map((point) => point.fillColor), [undefined, "#37b24d"], ); }); @@ -209,9 +225,9 @@ test("align_series matches dates by value rather than by identity", () => { ADDS_NOTHING_TO_THE_STACK, ); - assert.equal(a.data.length, 1); - assert.equal(b.data.length, 1); - assert.equal(b.data[0].y, 2); + assert.equal(a?.data.length, 1); + assert.equal(b?.data.length, 1); + assert.equal(b?.data[0]?.y, 2); }); test("align_series leaves a lone series in the order it arrived (#930)", () => { @@ -220,7 +236,7 @@ test("align_series leaves a lone series in the order it arrived (#930)", () => { LEAVES_A_GAP, ); - assert.deepEqual(only.data, [ + assert.deepEqual(only?.data, [ { x: "Q2", y: 1 }, { x: "Q1", y: 2 }, ]); @@ -256,7 +272,7 @@ test("align_series keeps the last of duplicated x values", () => { ADDS_NOTHING_TO_THE_STACK, ); - assert.deepEqual(a.data, [ + assert.deepEqual(a?.data, [ { x: "Q1", y: 9 }, { x: "Q2", y: 0 }, ]); @@ -269,7 +285,7 @@ test("align_series_for gives a stacked series a zero at every x it skipped", () STACKED, ); - assert.deepEqual(b.data, [ + assert.deepEqual(b?.data, [ { x: 1, y: 0 }, { x: 2, y: 3 }, ]); @@ -286,7 +302,7 @@ for (const type of ["line", "area", "scatter", "bubble", "heatmap"]) { UNSTACKED, ); - assert.deepEqual(b.data, [ + assert.deepEqual(b?.data, [ { x: "Q1", y: null }, { x: "Q2", y: 3 }, ]); @@ -303,7 +319,7 @@ test("align_series_for counts a label a bar series skipped as zero", () => { UNSTACKED, ); - assert.deepEqual(b.data, [ + assert.deepEqual(b?.data, [ { x: "Q1", y: 0 }, { x: "Q2", y: 3 }, ]); diff --git a/tests/js/component_documentation.spec.ts b/tests/js/component_documentation.spec.ts index c5dbc4ea..a9426cbe 100644 --- a/tests/js/component_documentation.spec.ts +++ b/tests/js/component_documentation.spec.ts @@ -144,6 +144,7 @@ function properties_of_template(source: string): TemplateProperties { const contexts: Context[] = ["top"]; for (const [, raw_body] of source.matchAll(/\{\{([^}]*)\}\}/g)) { + assert.ok(raw_body !== undefined); let body = raw_body .replace(/^[{~]+/, "") .replace(/[~}]+$/, "") @@ -203,7 +204,7 @@ function properties_of_template(source: string): TemplateProperties { continue; if (name.startsWith("this.")) name = name.slice(5); if (name === "this" || name === "") continue; - name = name.split(/[.[]/)[0]; // `a.b` and `a.[0]` are reads of `a` + name = name.split(/[.[]/)[0] ?? ""; // `a.b` and `a.[0]` are reads of `a` if (!/^[A-Za-z_][A-Za-z_0-9]*$/.test(name)) continue; if (LITERALS.has(name)) continue; if (parents === 0 && in_callee_position && HELPERS.has(name)) continue; diff --git a/tsconfig.json b/tsconfig.json index aa8793a1..eef2ff1b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,7 @@ "isolatedModules": true, "verbatimModuleSyntax": true, "strict": true, + "noUncheckedIndexedAccess": true, "noImplicitOverride": true, "noFallthroughCasesInSwitch": true, "noUnusedLocals": true,