diff --git a/CLAUDE.md b/CLAUDE.md index 3a65e0e..d8b4ec8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,8 +19,16 @@ ClickHouse. No framework, no runtime deps. Quality is held by tests. to browsers: prefer a PKCE public client; if an IdP requires a `client_secret` there, lock the redirect URI and treat the file as public (see README "Configuring OAuth"). -4. **The build is esbuild only.** Source files are the tested files; esbuild - bundles `src/main.js` → `dist/sql.html`. Don't add a runtime dependency. +4. **The build is esbuild only; runtime deps are rare and deliberate.** Source + files are the tested files; esbuild bundles `src/main.js` → `dist/sql.html`. + The **one** bundled runtime dependency is **Chart.js** (the Chart result + view) — inlined into the artifact, so the page still makes zero third-party + requests. Adding *another* runtime dependency is a deliberate decision (it + grows the single served file) — don't do it casually. When a feature needs a + library, keep the testable logic pure in `src/core/` (chart axis/role/pivot + math lives in `src/core/chart-data.js`, 100%-covered) and make the library + call an **injected seam** (`app.Chart`, like the fetch/crypto seams) so the + DOM wrapper stays fully tested rather than dropping below the coverage gate. ## How to add a result view / panel / feature diff --git a/README.md b/README.md index e78a88f..657dc20 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ # Altinity SQL Browser -A zero-dependency, OAuth-gated **SQL browser for any ClickHouse cluster** — -schema explorer, tabbed SQL editor with syntax highlighting, streaming results -with table / JSON / chart views, saved queries, history, and shareable links. -It ships as a **single self-contained HTML file served from ClickHouse itself** -(no Node server, no CDN, no external fonts, no runtime dependencies) — the page -makes **zero third-party requests** and renders in the OS's native UI font. +An OAuth-gated **SQL browser for any ClickHouse cluster** — schema explorer, +tabbed SQL editor with syntax highlighting, streaming results with table / JSON +/ chart views, saved queries, history, and shareable links. It ships as a +**single self-contained HTML file served from ClickHouse itself** (no Node +server, no CDN, no external fonts) — the page makes **zero third-party +requests** and renders in the OS's native UI font. Its only bundled runtime +dependency is **Chart.js** (the chart result view), inlined into that one file. Refactored from a single-file SPA into a fully modular, test-first codebase held at **100% test coverage**. @@ -196,7 +197,8 @@ Preview the rendered artifacts without touching ClickHouse: ``` src/ core/ pure logic — format, jwt, pkce, sql-highlight, share, sort, - stream, storage, chart-data (no DOM, no globals) + stream, storage, chart-data (roles/autoChart/pivot + Chart.js + config builder; no DOM, no globals) net/ oauth-config, oauth, ch-client (injected fetch seam) ui/ dom (hyperscript), icons, + render modules (login, editor, tabs, schema, results, saved-history, shortcuts, splitters, toast, app) diff --git a/build/build.mjs b/build/build.mjs index 34f9f60..3738537 100644 --- a/build/build.mjs +++ b/build/build.mjs @@ -1,9 +1,10 @@ // Build the single-file SPA: esbuild bundles src/main.js into one IIFE, which // is inlined (with the stylesheet) into build/template.html → dist/sql.html. // -// esbuild is the only build-time tool; the runtime ships zero dependencies. -// The output is a self-contained HTML file that installs into any ClickHouse -// cluster's user_files and is served by an static rule. +// esbuild is the only build-time tool; the sole bundled runtime dependency is +// Chart.js (inlined, not fetched). The output is a self-contained HTML file +// that installs into any ClickHouse cluster's user_files and is served by an +// static rule — it still makes zero third-party requests. import { build } from 'esbuild'; import { readFile, writeFile, mkdir } from 'node:fs/promises'; diff --git a/package.json b/package.json index bed332f..eff8f1b 100644 --- a/package.json +++ b/package.json @@ -18,5 +18,8 @@ "esbuild": "^0.21.5", "happy-dom": "^15.11.0", "vitest": "^2.1.8" + }, + "dependencies": { + "chart.js": "^4.5.1" } } diff --git a/src/core/chart-data.js b/src/core/chart-data.js index 27fb61d..5d45cc6 100644 --- a/src/core/chart-data.js +++ b/src/core/chart-data.js @@ -1,37 +1,344 @@ -// Pure helpers for the bar-chart result view: pick X/Y columns and derive the -// plotted series. Kept out of the DOM renderer so the axis logic is testable. +// Pure helpers for the chart result view. Everything here is DOM-free and +// library-agnostic up to the final `chartJsConfig`, which assembles a plain +// Chart.js config *object* (no canvas, no globals) — so the whole role/axis/ +// pivot/scale layer is unit-testable at 100% and the DOM glue in +// `ui/results.js` stays a thin wrapper around `new Chart(canvas, config)`. import { isNumericType } from './format.js'; +const TIME_RE = /^(Date|DateTime)/; +// Numeric columns whose *name is exactly* a calendar bucket (year, month, …) +// are ordinal, not free measures — a `GROUP BY toYear(...) AS year` is an X +// axis. Anchored at both ends (optional plural) so a real measure like +// `monthly_revenue` / `minutes_watched` / `dayrate` stays a measure rather +// than being misclassified by a mere prefix and dropped from autoChart. +const ORDINAL_RE = /^(year|quarter|month|week|day|dayofweek|dow|hour|minute)s?$/i; + +// Plots past this get unreadable, so the chart shows the first N (the table +// stays full). Exported so the renderer can surface the truncation to the user. +export const CHART_ROW_CAP = 500; + +/** Strip `Nullable(...)` / `LowCardinality(...)` wrappers down to the base type. */ +export function chartStripType(type) { + let p = String(type || ''); + let m; + while ((m = /^(?:Nullable|LowCardinality)\((.*)\)$/.exec(p))) p = m[1]; + return p; +} + +/** + * Classify a column for charting from its ClickHouse type (and, for numbers, + * its name): 'time' | 'ordinal' | 'measure' | 'category'. + */ +export function chartRole(col) { + const t = chartStripType(col && col.type); + if (TIME_RE.test(t)) return 'time'; + // Wrappers already stripped, so reuse the table's numeric test on the base type. + if (isNumericType(t)) return ORDINAL_RE.test((col && col.name) || '') ? 'ordinal' : 'measure'; + return 'category'; +} + +/** + * Default chart config from column roles, or null when nothing is plottable + * (no numeric measure). Temporal X → line, categorical X → horizontal bar, + * ordinal X → vertical column. The config bar lets the user override the rest. + * Returns { type, x, y:[idx], series:null }. + */ +export function autoChart(columns) { + const cols = columns || []; + const roles = cols.map((c, i) => ({ i, role: chartRole(c) })); + const measures = roles.filter((r) => r.role === 'measure').map((r) => r.i); + if (!measures.length) return null; + // A measure exists ⇒ roles is non-empty ⇒ the `|| roles[0]` fallback always + // resolves, so x is guaranteed defined here. + const x = roles.find((r) => r.role === 'time') + || roles.find((r) => r.role === 'ordinal') + || roles.find((r) => r.role === 'category') + || roles[0]; + const type = x.role === 'time' ? 'line' : x.role === 'category' ? 'hbar' : 'bar'; + return { type, x: x.i, y: [measures[0]], series: null }; +} + +/** A stable signature of the result schema; chart config is re-derived when it changes. */ +export function schemaKey(columns) { + return (columns || []).map((c) => c.name + ':' + c.type).join('|'); +} + +/** The chart types offered in the config bar (Bar = horizontal, Column = vertical). */ +export const CHART_TYPES = [ + { value: 'hbar', label: 'Bar' }, + { value: 'bar', label: 'Column' }, + { value: 'line', label: 'Line' }, + { value: 'area', label: 'Area' }, + { value: 'pie', label: 'Pie' }, +]; + +const CHART_TYPE_SET = new Set(CHART_TYPES.map((t) => t.value)); + +/** + * Deep-clone a chart config (`y` is an array) so a config restored from a saved + * query / share link never shares a reference with its source — editing the + * restored chart must not mutate the saved entry. null → null. + */ +export function cloneChartCfg(cfg) { + return cfg ? { type: cfg.type, x: cfg.x, y: [...(cfg.y || [])], series: cfg.series ?? null } : null; +} + /** - * Choose which columns to plot. Prefer the first non-numeric column as X - * (categorical label) and the first numeric column as Y. When every column is - * numeric (e.g. `year, flights`), fall back to col 0 = X, col 1 = Y; with a - * single column, plot it against itself. - * - * Returns { xIdx, yIdx, ok } where ok=false means there is no numeric Y. - */ -export function pickChartAxes(columns) { - if (!columns || columns.length === 0) return { xIdx: 0, yIdx: 0, ok: false }; - let xIdx = columns.findIndex((c) => !isNumericType(c.type)); - let yIdx = columns.findIndex((c, i) => i !== xIdx && isNumericType(c.type)); - if (xIdx === -1) { - xIdx = 0; - yIdx = columns.length > 1 ? 1 : 0; + * Is a (possibly untrusted) chart config structurally valid for `columns`? + * Restored configs come from saved JSON / a URL hash a user can hand-edit, so + * before `chartJsConfig` dereferences `cfg.x` / `cfg.y[i]` / `cfg.series` as + * column indices we confirm the type is known and every index is in range — + * otherwise the caller falls back to `autoChart`. + */ +export function chartCfgValid(cfg, columns) { + if (!cfg || typeof cfg !== 'object') return false; + const n = (columns || []).length; + const idxOk = (i) => Number.isInteger(i) && i >= 0 && i < n; + if (!CHART_TYPE_SET.has(cfg.type)) return false; + if (!idxOk(cfg.x)) return false; + if (!Array.isArray(cfg.y) || cfg.y.length === 0 || !cfg.y.every(idxOk)) return false; + if (cfg.series != null && !idxOk(cfg.series)) return false; + return true; +} + +/** + * Derive the config-bar option lists + visibility flags for the current config. + * Pure so the glue just maps these to for the config bar. */ +function chartSelect(label, value, options, onChange) { + const sel = h('select', { class: 'chart-select', onchange: (e) => onChange(e.target.value) }); + for (const o of options) { + const opt = h('option', { value: o.value }, o.label); + if (o.value === value) opt.selected = true; + sel.appendChild(opt); + } + return h('label', { class: 'chart-field' }, h('span', { class: 'chart-field-label' }, label), sel); +} - const svg = s('svg', { - viewBox: `0 0 ${W} ${H}`, preserveAspectRatio: 'xMidYMid meet', - style: { width: '100%', height: '100%' }, - }); +function chartEmpty(icon, msg) { + return h('div', { class: 'chart-empty' }, h('div', { class: 'chip' }, icon), h('div', null, msg)); +} - const line = (x1, y1, x2, y2) => s('line', { x1, y1, x2, y2, stroke: 'currentColor', opacity: '0.25' }); - const text = (x, y, anchor, str) => - s('text', { x, y, 'text-anchor': anchor, 'font-size': '10', fill: 'currentColor', opacity: '0.6' }, str); +export function renderChart(app, r) { + const tab = app.activeTab(); + // Gate on run state BEFORE deriving the config: while a query streams its + // columns can be empty (pre-meta), and letting chartCfgFor see that empty + // schema would clobber a restored saved/shared config with autoChart(null). + if (app.state.running) return chartEmpty(Icon.spinner(), 'Chart renders when the query completes.'); + const cfg = chartCfgFor(tab, r.columns); + if (!cfg) return chartEmpty(Icon.chart(), 'These results aren’t chartable — add a numeric column to plot them.'); - svg.appendChild(line(P.l, P.t + innerH, P.l + innerW, P.t + innerH)); - svg.appendChild(text(P.l - 6, P.t + 10, 'end', formatRows(max))); - svg.appendChild(text(P.l - 6, P.t + innerH + 4, 'end', '0')); + const f = chartFieldOptions(r.columns, cfg); + const rerender = () => renderResults(app); - values.forEach((v, i) => { - const x = P.l + i * step + (step - barW) / 2; - const hgt = max > 0 ? (v / max) * innerH : 0; - svg.appendChild(s('rect', { - x, y: P.t + innerH - hgt, width: barW, height: hgt, fill: 'var(--accent)', rx: '1.5', - }, s('title', null, labels[i] + ': ' + v))); - }); + // Each handler mutates the shared cfg (= tab.chartCfg) and re-renders; + // chartCfgFor folds the cross-field invariants (pie → single measure, + // series ≠ X) on the way back in, so the handlers don't normalize themselves. + const bar = h('div', { class: 'chart-config' }); + bar.appendChild(chartSelect('Type', cfg.type, f.typeOptions, (v) => { cfg.type = v; rerender(); })); + bar.appendChild(chartSelect('X', String(cfg.x), f.xOptions, (v) => { cfg.x = Number(v); rerender(); })); + bar.appendChild(chartSelect('Y', String(cfg.y[0]), f.yOptions, (v) => { cfg.y = [Number(v)]; rerender(); })); + if (f.showMulti) { + bar.appendChild(h('button', { + class: 'chart-toggle', title: 'Plot every numeric column as its own series', + onclick: () => { cfg.y = f.multiActive ? [cfg.y[0]] : f.allMeasures; rerender(); }, + }, f.multiActive ? 'Single series' : 'All measures')); + } + if (f.showSeries) { + bar.appendChild(chartSelect('Series', String(cfg.series ?? ''), f.seriesOptions, (v) => { + cfg.series = v === '' ? null : Number(v); + rerender(); + })); + } + // The chart plots at most CHART_ROW_CAP points; say so when the result is + // bigger (the table still shows everything) — no silent truncation. + if (r.rows.length > CHART_ROW_CAP) { + bar.appendChild(h('span', { class: 'chart-cap-note' }, + 'first ' + CHART_ROW_CAP + ' of ' + formatRows(r.rows.length) + ' rows')); + } - const every = Math.max(1, Math.ceil(labels.length / 12)); - labels.forEach((lab, i) => { - if (i % every !== 0) return; - const short = lab.length > 12 ? lab.slice(0, 11) + '…' : lab; - svg.appendChild(text(P.l + i * step + step / 2, P.t + innerH + 16, 'middle', short)); - }); + const canvas = document.createElement('canvas'); + // Plot in result (query) order — independent of the table's sort, which is a + // global, cross-tab setting; applying it here would reorder the X axis (a + // time series would zig-zag) and change which rows the CHART_ROW_CAP keeps, + // contradicting the "first N rows" note. It would also sort up to VIS_CAP + // rows just to discard all but the first CHART_ROW_CAP. + app.chart = new app.Chart(canvas, chartJsConfig(r.columns, r.rows, cfg, chartColors(app.cssVar))); - const area = h('div', { class: 'chart-area' }); - area.appendChild(svg); - wrap.appendChild(area); - return wrap; + return h('div', { class: 'chart-view' }, bar, h('div', { class: 'chart-canvas-wrap' }, canvas)); } diff --git a/src/ui/saved-history.js b/src/ui/saved-history.js index c8f3462..44e989c 100644 --- a/src/ui/saved-history.js +++ b/src/ui/saved-history.js @@ -68,7 +68,7 @@ function renderSaved(app, list) { nameEl = h('span', { class: 'name' }, q.name); } - const row = h('div', { class: 'saved-row', onclick: () => { if (!editing) app.actions.loadIntoNewTab(q.name, q.sql, q.id); } }, + const row = h('div', { class: 'saved-row', onclick: () => { if (!editing) { app.actions.loadIntoNewTab(q.name, q.sql, q.id, q.chart); app.actions.run({ view: q.view }); } } }, h('div', { class: 'top' }, star, nameEl, @@ -112,7 +112,7 @@ function renderHistory(app, list) { return; } for (const ent of state.history) { - list.appendChild(h('div', { class: 'history-row', onclick: () => app.actions.loadIntoNewTab('From history', ent.sql) }, + list.appendChild(h('div', { class: 'history-row', onclick: () => { app.actions.loadIntoNewTab('From history', ent.sql); app.actions.run(); } }, h('button', { class: 'sv-act del', title: 'Delete', onclick: (e) => { e.stopPropagation(); deleteHistory(state, ent.id, app.saveJSON); renderSavedHistory(app); }, diff --git a/src/ui/tabs.js b/src/ui/tabs.js index 40e7e46..9a1fc1a 100644 --- a/src/ui/tabs.js +++ b/src/ui/tabs.js @@ -4,6 +4,7 @@ import { h } from './dom.js'; import { Icon } from './icons.js'; import { activeTab, allocTabId, newTabObj } from '../state.js'; +import { cloneChartCfg } from '../core/chart-data.js'; /** Paint the tab strip into app.dom.qtabsInner. */ export function renderTabs(app) { @@ -50,14 +51,20 @@ export function newTab(app) { /** * Open a tab pre-seeded with `name`/`sql` (used by saved/history). `savedId` * links it to a saved query so the Save button reads "Saved" (restoring a saved - * query); omit it for history entries, which aren't saved. + * query); omit it for history entries, which aren't saved. `chart` is the saved + * chart config `{ cfg, key }`, cloned onto the tab. (The result view is a global + * setting restored via `run({ view })` by the caller, since `run` resets it.) */ -export function loadIntoNewTab(app, name, sql, savedId = null) { +export function loadIntoNewTab(app, name, sql, savedId = null, chart = null) { const id = allocTabId(app.state); const tab = newTabObj(id); tab.name = name || 'Untitled'; tab.sql = sql; tab.savedId = savedId; + if (chart && chart.cfg) { + tab.chartCfg = cloneChartCfg(chart.cfg); + tab.chartKey = chart.key ?? null; + } app.state.tabs.push(tab); app.state.activeTabId = id; refresh(app); diff --git a/tests/helpers/fake-app.js b/tests/helpers/fake-app.js index eb781b5..d2b02d3 100644 --- a/tests/helpers/fake-app.js +++ b/tests/helpers/fake-app.js @@ -4,6 +4,17 @@ import { vi } from 'vitest'; import { createState, activeTab } from '../../src/state.js'; +// A stand-in for the Chart.js constructor: records its canvas + config and +// exposes a destroy() spy, so the chart glue is testable without a real canvas. +export class FakeChart { + constructor(canvas, config) { + this.canvas = canvas; + this.config = config; + this.destroyed = false; + } + destroy() { this.destroyed = true; } +} + export function makeApp(over = {}) { const state = createState({ loadStr: (k, d) => d, loadJSON: (k, d) => d }); const root = document.createElement('div'); @@ -11,6 +22,9 @@ export function makeApp(over = {}) { state, root, document, + Chart: FakeChart, + cssVar: () => '', // blank → chartColors() uses its dark-theme fallbacks + chart: null, host: () => 'test.host', activeTab: () => activeTab(state), isSignedIn: () => true, diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index 42a2ac6..bc100dd 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -205,6 +205,20 @@ describe('query run', () => { expect(app.activeTab().result.rows).toEqual([['1']]); expect(app.state.history.length).toBe(1); }); + it('opens in a restored result view, defaulting to table for an unknown/absent view', async () => { + const routes = [[(u, sql) => /SELECT 1/.test(sql), resp({ body: streamBody(['{"meta":[{"name":"a","type":"UInt8"}]}\n', '{"row":{"a":"1"}}\n']) })]]; + const { app } = appForRun(routes); + app.activeTab().sql = 'SELECT 1'; + app.state.resultView = 'chart'; + await app.actions.run(); // no opts → resets to table + expect(app.state.resultView).toBe('table'); + await app.actions.run({ view: 'chart' }); // restore a saved chart view + expect(app.state.resultView).toBe('chart'); + await app.actions.run({ view: 'json' }); + expect(app.state.resultView).toBe('json'); + await app.actions.run({ view: 'bogus' }); // unknown view → table + expect(app.state.resultView).toBe('table'); + }); it('no-ops on empty SQL', async () => { const { app } = appForRun([]); app.activeTab().sql = ' '; diff --git a/tests/unit/chart-data.test.js b/tests/unit/chart-data.test.js index e51b086..305ebf6 100644 --- a/tests/unit/chart-data.test.js +++ b/tests/unit/chart-data.test.js @@ -1,48 +1,348 @@ import { describe, it, expect } from 'vitest'; -import { pickChartAxes, chartSeries } from '../../src/core/chart-data.js'; +import { + chartStripType, chartRole, autoChart, schemaKey, CHART_TYPES, chartFieldOptions, + chartNumFmt, chartLabel, chartPalette, chartColors, buildChartData, chartJsConfig, + cloneChartCfg, chartCfgValid, normalizeChartCfg, +} from '../../src/core/chart-data.js'; -describe('pickChartAxes', () => { - it('returns not-ok for empty columns', () => { - expect(pickChartAxes([])).toEqual({ xIdx: 0, yIdx: 0, ok: false }); - expect(pickChartAxes(null)).toEqual({ xIdx: 0, yIdx: 0, ok: false }); +describe('chartStripType', () => { + it('strips Nullable/LowCardinality, including nested', () => { + expect(chartStripType('String')).toBe('String'); + expect(chartStripType('Nullable(UInt64)')).toBe('UInt64'); + expect(chartStripType('LowCardinality(Nullable(String))')).toBe('String'); }); - it('prefers categorical X + numeric Y', () => { - const cols = [{ type: 'String' }, { type: 'UInt64' }]; - expect(pickChartAxes(cols)).toEqual({ xIdx: 0, yIdx: 1, ok: true }); + it('coerces nullish to empty string', () => { + expect(chartStripType(null)).toBe(''); + expect(chartStripType(undefined)).toBe(''); }); - it('all-numeric falls back to col0=X, col1=Y', () => { - const cols = [{ type: 'UInt16' }, { type: 'UInt64' }]; - expect(pickChartAxes(cols)).toEqual({ xIdx: 0, yIdx: 1, ok: true }); +}); + +describe('chartRole', () => { + it('classifies temporal, measure, ordinal and category', () => { + expect(chartRole({ name: 'ts', type: 'DateTime' })).toBe('time'); + expect(chartRole({ name: 'd', type: 'Date' })).toBe('time'); + expect(chartRole({ name: 'flights', type: 'UInt64' })).toBe('measure'); + expect(chartRole({ name: 'Year', type: 'UInt16' })).toBe('ordinal'); + expect(chartRole({ name: 'months', type: 'UInt16' })).toBe('ordinal'); // plural bucket + expect(chartRole({ name: 'carrier', type: 'LowCardinality(String)' })).toBe('category'); + }); + it('treats a numeric column with no name as a measure, and a missing col as category', () => { + expect(chartRole({ type: 'Float64' })).toBe('measure'); + expect(chartRole(undefined)).toBe('category'); + }); + it('does not misclassify a measure merely prefixed with a bucket word', () => { + // anchored regex: a real measure named like a bucket stays a measure + expect(chartRole({ name: 'monthly_revenue', type: 'Float64' })).toBe('measure'); + expect(chartRole({ name: 'minutes_watched', type: 'UInt64' })).toBe('measure'); + expect(chartRole({ name: 'dayrate', type: 'Float64' })).toBe('measure'); + }); +}); + +describe('autoChart', () => { + it('returns null when there is no measure (or no columns)', () => { + expect(autoChart(null)).toBeNull(); + expect(autoChart([])).toBeNull(); + expect(autoChart([{ name: 'a', type: 'String' }, { name: 'b', type: 'String' }])).toBeNull(); + }); + it('temporal X → line', () => { + expect(autoChart([{ name: 'd', type: 'Date' }, { name: 'n', type: 'UInt64' }])) + .toEqual({ type: 'line', x: 0, y: [1], series: null }); + }); + it('categorical X → horizontal bar', () => { + expect(autoChart([{ name: 'c', type: 'String' }, { name: 'n', type: 'UInt64' }])) + .toEqual({ type: 'hbar', x: 0, y: [1], series: null }); + }); + it('ordinal X → vertical column', () => { + expect(autoChart([{ name: 'month', type: 'UInt8' }, { name: 'n', type: 'UInt64' }])) + .toEqual({ type: 'bar', x: 0, y: [1], series: null }); + }); + it('all-measure result falls back to col 0 as X (bar)', () => { + expect(autoChart([{ name: 'a', type: 'UInt64' }, { name: 'b', type: 'Float64' }])) + .toEqual({ type: 'bar', x: 0, y: [0], series: null }); + }); + it('charts a sole numeric measure even when its name is prefixed with a bucket word', () => { + // regression: `monthly_total` must not be misread as an ordinal axis → null + expect(autoChart([{ name: 'carrier', type: 'String' }, { name: 'monthly_total', type: 'Float64' }])) + .toEqual({ type: 'hbar', x: 0, y: [1], series: null }); + }); +}); + +describe('schemaKey', () => { + it('signs the schema and is empty for none', () => { + expect(schemaKey(null)).toBe(''); + expect(schemaKey([{ name: 'a', type: 'String' }, { name: 'b', type: 'UInt8' }])) + .toBe('a:String|b:UInt8'); + }); +}); + +describe('chartFieldOptions', () => { + const cols = [ + { name: 'carrier', type: 'String' }, + { name: 'region', type: 'LowCardinality(String)' }, + { name: 'flights', type: 'UInt64' }, + { name: 'delay', type: 'Float64' }, + ]; + it('builds X/Y/Series options and visibility flags (non-pie, single Y)', () => { + const f = chartFieldOptions(cols, { type: 'hbar', x: 0, y: [2], series: null }); + expect(f.typeOptions).toBe(CHART_TYPES); + expect(f.xOptions.map((o) => o.label)).toEqual(['carrier', 'region', 'flights', 'delay']); + expect(f.yOptions.map((o) => o.label)).toEqual(['flights', 'delay']); + // series = category-ish columns except the current X (carrier), plus None + expect(f.seriesOptions.map((o) => o.label)).toEqual(['None', 'region']); + expect(f.showSeries).toBe(true); + expect(f.showMulti).toBe(true); + expect(f.multiActive).toBe(false); + expect(f.allMeasures).toEqual([2, 3]); + }); + it('hides multi-toggle when a group-by series is set; reports multiActive for multi-Y', () => { + const f = chartFieldOptions(cols, { type: 'bar', x: 0, y: [2, 3], series: 1 }); + expect(f.showMulti).toBe(false); // series set + expect(f.multiActive).toBe(true); + }); + it('hides series + multi for pie', () => { + const f = chartFieldOptions(cols, { type: 'pie', x: 0, y: [2], series: null }); + expect(f.showSeries).toBe(false); + expect(f.showMulti).toBe(false); + }); + it('handles a config with no y array (defaults multiActive false)', () => { + const f = chartFieldOptions(cols, { type: 'hbar', x: 0, series: null }); + expect(f.multiActive).toBe(false); + }); + it('"All measures" excludes ordinal buckets and the current X column', () => { + const c = [{ name: 'year', type: 'UInt16' }, { name: 'requests', type: 'UInt64' }, { name: 'users', type: 'UInt64' }]; + // year is an ordinal X; it stays pickable as Y but is not an "All measures" target. + const onYear = chartFieldOptions(c, { type: 'bar', x: 0, y: [1], series: null }); + expect(onYear.yOptions.map((o) => o.label)).toEqual(['year', 'requests', 'users']); + expect(onYear.allMeasures).toEqual([1, 2]); + // when X is itself a measure, it's excluded from allMeasures (and the toggle hides at <2 left). + const onMeasure = chartFieldOptions(c, { type: 'bar', x: 1, y: [2], series: null }); + expect(onMeasure.allMeasures).toEqual([2]); + expect(onMeasure.showMulti).toBe(false); + }); +}); + +describe('chartNumFmt', () => { + it('humanizes numbers and passes through non-finite/non-numbers', () => { + expect(chartNumFmt(2_500_000)).toBe('2.5M'); + expect(chartNumFmt(1500)).toBe('1.5K'); + expect(chartNumFmt(42)).toBe('42'); + expect(chartNumFmt(3.14159)).toBe('3.14'); + expect(chartNumFmt(-1_000_000)).toBe('-1.0M'); + expect(chartNumFmt(NaN)).toBe('NaN'); + expect(chartNumFmt('x')).toBe('x'); + }); +}); + +describe('chartLabel', () => { + it('keeps date-only for a Date or midnight DateTime', () => { + expect(chartLabel('2026-06-21')).toBe('2026-06-21'); + expect(chartLabel('2026-06-21 00:00:00')).toBe('2026-06-21'); // day-level aggregation + }); + it('keeps date + HH:MM for an intraday timestamp (so same-day buckets stay distinct)', () => { + expect(chartLabel('2026-06-21 12:30:45')).toBe('2026-06-21 12:30'); + expect(chartLabel('2026-06-21T09:05:00')).toBe('2026-06-21 09:05'); // ISO 'T' separator + }); + it('stringifies non-date values', () => { + expect(chartLabel('B6')).toBe('B6'); + expect(chartLabel(7)).toBe('7'); + }); +}); + +describe('normalizeChartCfg', () => { + it('null → null', () => { + expect(normalizeChartCfg(null)).toBeNull(); + }); + it('clears a series that equals the X column', () => { + expect(normalizeChartCfg({ type: 'bar', x: 2, y: [1], series: 2 })) + .toEqual({ type: 'bar', x: 2, y: [1], series: null }); + }); + it('leaves a distinct series untouched', () => { + expect(normalizeChartCfg({ type: 'bar', x: 0, y: [1], series: 2 })) + .toEqual({ type: 'bar', x: 0, y: [1], series: 2 }); + }); + it('forces pie to a single measure and no series', () => { + expect(normalizeChartCfg({ type: 'pie', x: 0, y: [1, 2], series: 3 })) + .toEqual({ type: 'pie', x: 0, y: [1], series: null }); + }); + it('tolerates a pie with a missing/short y array', () => { + expect(normalizeChartCfg({ type: 'pie', x: 0, y: [1], series: null })) + .toEqual({ type: 'pie', x: 0, y: [1], series: null }); + expect(normalizeChartCfg({ type: 'pie', x: 0, series: 2 })) + .toEqual({ type: 'pie', x: 0, series: null }); + }); +}); + +describe('chartPalette', () => { + it('anchors on the accent', () => { + const p = chartPalette('#FF6B35'); + expect(p[0]).toBe('#FF6B35'); + expect(p.length).toBeGreaterThan(3); + }); +}); + +describe('chartColors', () => { + it('falls back to dark-theme defaults when the reader is missing or blank', () => { + const c = chartColors(null); + expect(c.accent).toBe('#0079AD'); + expect(c.border).toBe('#1F1F26'); + expect(c.palette[0]).toBe('#0079AD'); + }); + it('uses resolved values when present, trimming whitespace', () => { + const c = chartColors((name) => (name === '--accent' ? ' #fff ' : '')); + expect(c.accent).toBe('#fff'); + expect(c.fg).toBe('#E6E6E8'); // blank → fallback + }); + it('resolves a real --mono font stack (canvas can\'t use var(--mono))', () => { + expect(chartColors(null).mono).toContain('monospace'); // fallback stack + expect(chartColors((name) => (name === '--mono' ? 'Courier' : '')).mono).toBe('Courier'); + }); +}); + +describe('buildChartData', () => { + const cols = [ + { name: 'carrier', type: 'String' }, + { name: 'flights', type: 'UInt64' }, + { name: 'delay', type: 'Float64' }, + { name: 'region', type: 'String' }, + ]; + it('single series per measure, coercing nullish/garbage to 0', () => { + const rows = [['B6', '10', '5.5', 'E'], ['AA', null, 'x', 'W'], ['DL', '', '2', 'W']]; + const out = buildChartData(cols, rows, { type: 'hbar', x: 0, y: [1, 2], series: null }); + expect(out.labels).toEqual(['B6', 'AA', 'DL']); + expect(out.datasets).toEqual([ + { label: 'flights', data: [10, 0, 0] }, + { label: 'delay', data: [5.5, 0, 2] }, + ]); }); - it('single numeric column plots against itself', () => { - const cols = [{ type: 'UInt64' }]; - expect(pickChartAxes(cols)).toEqual({ xIdx: 0, yIdx: 0, ok: true }); + it('group-by pivots into one aligned dataset per series value, missing → 0', () => { + const rows = [ + ['B6', '10', '1', 'E'], + ['AA', '20', '1', 'W'], + ['B6', '30', '1', 'W'], // second region for B6 + ]; + const out = buildChartData(cols, rows, { type: 'bar', x: 0, y: [1], series: 3 }); + expect(out.labels).toEqual(['B6', 'AA']); // first-seen X order, deduped + expect(out.datasets).toEqual([ + { label: 'E', data: [10, 0] }, // E has only B6 + { label: 'W', data: [30, 20] }, // W has B6(30) and AA(20) + ]); }); - it('not ok when no numeric column exists', () => { - const cols = [{ type: 'String' }, { type: 'String' }]; - const r = pickChartAxes(cols); - expect(r.ok).toBe(false); + it('caps at the row cap', () => { + const big = Array.from({ length: 600 }, (_, i) => ['c' + i, String(i)]); + const out = buildChartData([{ name: 'c', type: 'String' }, { name: 'n', type: 'UInt64' }], big, + { type: 'hbar', x: 0, y: [1], series: null }); + expect(out.labels).toHaveLength(500); + }); + it('aggregates (sums) rows sharing an X bucket — single-series path', () => { + // two rows for the same carrier are summed, not last-write-wins + const rows = [['B6', '10', '1', 'E'], ['B6', '30', '4', 'W'], ['AA', '20', '2', 'W']]; + const out = buildChartData(cols, rows, { type: 'bar', x: 0, y: [1, 2], series: null }); + expect(out.labels).toEqual(['B6', 'AA']); // deduped + expect(out.datasets).toEqual([ + { label: 'flights', data: [40, 20] }, // B6: 10+30 + { label: 'delay', data: [5, 2] }, // B6: 1+4 + ]); + }); + it('aggregates (sums) rows sharing an (X, series) cell — group-by path', () => { + const rows = [['B6', '10', '1', 'W'], ['B6', '30', '1', 'W']]; // same carrier+region + const out = buildChartData(cols, rows, { type: 'bar', x: 0, y: [1], series: 3 }); + expect(out.labels).toEqual(['B6']); + expect(out.datasets).toEqual([{ label: 'W', data: [40] }]); // 10+30, not last-wins(30) + }); + it('groups on the raw X value so two times on the same day stay distinct', () => { + const c = [{ name: 'ts', type: 'DateTime' }, { name: 'n', type: 'UInt64' }]; + const rows = [['2026-06-15 09:00:00', '1'], ['2026-06-15 17:00:00', '2']]; + const out = buildChartData(c, rows, { type: 'line', x: 0, y: [1], series: null }); + expect(out.labels).toEqual(['2026-06-15 09:00', '2026-06-15 17:00']); // distinct intraday ticks + expect(out.datasets[0].data).toEqual([1, 2]); // and the two points survive (no merge) + }); +}); + +describe('chartJsConfig', () => { + const cols = [{ name: 'carrier', type: 'String' }, { name: 'flights', type: 'UInt64' }, { name: 'delay', type: 'Float64' }]; + const rows = [['B6', '2026-01-01', '5'], ['AA', '20', '6']]; + const colors = chartColors(null); + + it('horizontal bar maps to type bar with indexAxis y and flipped scales', () => { + const cfg = chartJsConfig(cols, rows, { type: 'hbar', x: 0, y: [1], series: null }, colors); + expect(cfg.type).toBe('bar'); + expect(cfg.options.indexAxis).toBe('y'); + expect(cfg.options.scales.x.beginAtZero).toBe(true); // value axis on x + expect(cfg.options.scales.y.grid.display).toBe(false); // category axis + expect(cfg.data.datasets[0].backgroundColor).toBe(colors.palette[0]); + }); + it('vertical column keeps indexAxis x', () => { + const cfg = chartJsConfig(cols, rows, { type: 'bar', x: 0, y: [1], series: null }, colors); + expect(cfg.type).toBe('bar'); + expect(cfg.options.indexAxis).toBe('x'); + expect(cfg.options.scales.y.beginAtZero).toBe(true); + }); + it('value-axis ticks humanize via callback (number and coercible string)', () => { + const cfg = chartJsConfig(cols, rows, { type: 'bar', x: 0, y: [1], series: null }, colors); + const cb = cfg.options.scales.y.ticks.callback; + expect(cb(2_000_000)).toBe('2.0M'); + expect(cb('1500')).toBe('1.5K'); + }); + it('line is not filled; area fills with an alpha-blended hex', () => { + const line = chartJsConfig(cols, rows, { type: 'line', x: 0, y: [1], series: null }, colors); + expect(line.type).toBe('line'); + expect(line.data.datasets[0].fill).toBe(false); + const area = chartJsConfig(cols, rows, { type: 'area', x: 0, y: [1], series: null }, colors); + expect(area.data.datasets[0].fill).toBe(true); + expect(area.data.datasets[0].backgroundColor).toMatch(/^rgba\(/); + }); + it('area leaves a non-hex accent color untouched (withAlpha passthrough)', () => { + const c = { ...colors, palette: ['rgb(1,2,3)', '#22C55E'] }; + const area = chartJsConfig(cols, rows, { type: 'area', x: 0, y: [1], series: null }, c); + expect(area.data.datasets[0].backgroundColor).toBe('rgb(1,2,3)'); + }); + it('pie has no scales, per-slice colors, and a right-positioned legend', () => { + const cfg = chartJsConfig(cols, rows, { type: 'pie', x: 0, y: [1], series: null }, colors); + expect(cfg.type).toBe('pie'); + expect(cfg.options.scales).toBeUndefined(); + expect(Array.isArray(cfg.data.datasets[0].backgroundColor)).toBe(true); + expect(cfg.options.plugins.legend.display).toBe(true); + expect(cfg.options.plugins.legend.position).toBe('right'); + }); + it('multi-series shows a top legend', () => { + const cfg = chartJsConfig(cols, rows, { type: 'bar', x: 0, y: [1, 2], series: null }, colors); + expect(cfg.data.datasets).toHaveLength(2); + expect(cfg.options.plugins.legend.display).toBe(true); + expect(cfg.options.plugins.legend.position).toBe('top'); + }); +}); + +describe('cloneChartCfg', () => { + it('deep-copies cfg so y is not shared with the source', () => { + const src = { type: 'bar', x: 0, y: [1, 2], series: 3 }; + const c = cloneChartCfg(src); + expect(c).toEqual(src); + expect(c).not.toBe(src); + expect(c.y).not.toBe(src.y); }); - it('categorical X with no numeric Y reuses X as Y and is not ok', () => { - const cols = [{ type: 'String' }]; - expect(pickChartAxes(cols)).toEqual({ xIdx: 0, yIdx: 0, ok: false }); + it('null → null and defaults a missing y/series', () => { + expect(cloneChartCfg(null)).toBeNull(); + expect(cloneChartCfg({ type: 'pie', x: 0 })).toEqual({ type: 'pie', x: 0, y: [], series: null }); }); }); -describe('chartSeries', () => { - const rows = [['a', '5'], ['b', '10'], ['c', 'x']]; - it('builds labels, numeric values and max', () => { - expect(chartSeries(rows, 0, 1)).toEqual({ - labels: ['a', 'b', 'c'], - values: [5, 10, 0], // 'x' coerces to 0 - max: 10, - }); +describe('chartCfgValid', () => { + const cols = [{ name: 'a', type: 'String' }, { name: 'b', type: 'UInt64' }]; + it('accepts a well-formed config (series null or in range)', () => { + expect(chartCfgValid({ type: 'bar', x: 0, y: [1], series: null }, cols)).toBe(true); + expect(chartCfgValid({ type: 'pie', x: 0, y: [1], series: 0 }, cols)).toBe(true); }); - it('caps at the row limit', () => { - const big = Array.from({ length: 500 }, (_, i) => [String(i), String(i)]); - expect(chartSeries(big, 0, 1, 3).values).toEqual([0, 1, 2]); + it('rejects non-objects, unknown types, and out-of-range indices', () => { + expect(chartCfgValid(null, cols)).toBe(false); + expect(chartCfgValid('x', cols)).toBe(false); + expect(chartCfgValid({ type: 'donut', x: 0, y: [1], series: null }, cols)).toBe(false); + expect(chartCfgValid({ type: 'bar', x: 9, y: [1], series: null }, cols)).toBe(false); + expect(chartCfgValid({ type: 'bar', x: 0, y: [], series: null }, cols)).toBe(false); + expect(chartCfgValid({ type: 'bar', x: 0, y: [9], series: null }, cols)).toBe(false); + expect(chartCfgValid({ type: 'bar', x: 0, y: 'nope', series: null }, cols)).toBe(false); + expect(chartCfgValid({ type: 'bar', x: 0, y: [1], series: 9 }, cols)).toBe(false); }); - it('max is at least 0 for empty rows', () => { - expect(chartSeries([], 0, 1)).toEqual({ labels: [], values: [], max: 0 }); + it('treats missing columns as zero-length (nothing in range)', () => { + expect(chartCfgValid({ type: 'bar', x: 0, y: [0], series: null })).toBe(false); }); }); diff --git a/tests/unit/main.test.js b/tests/unit/main.test.js index b369f21..ed66f48 100644 --- a/tests/unit/main.test.js +++ b/tests/unit/main.test.js @@ -132,7 +132,7 @@ describe('bootstrap', () => { expect(app.showLogin).toHaveBeenCalledWith('OAuth token exchange failed: plain failure'); }); - it('seeds the first tab from a share-link hash (and stashes it for login)', async () => { + it('seeds the first tab from a legacy (SQL-only) share-link hash', async () => { const app = fakeApp(); const sql = 'SELECT 1'; const hash = '#' + btoa(unescape(encodeURIComponent(sql))); @@ -140,19 +140,43 @@ describe('bootstrap', () => { await bootstrap(app, env); expect(app.state.tabs[0].sql).toBe('SELECT 1'); expect(app.state.tabs[0].name).toBe('Shared query'); - expect(env.sessionStorage.getItem('oauth_shared_sql')).toBe('SELECT 1'); // survives a login redirect + expect(app.state.tabs[0].chartCfg).toBeFalsy(); // legacy hash carries no chart + expect(JSON.parse(env.sessionStorage.getItem('oauth_shared'))).toEqual({ sql: 'SELECT 1', chart: null }); // survives a login redirect }); - it('restores a shared query from sessionStorage after the OAuth round-trip', async () => { + it('seeds SQL + chart config from a tagged share-link hash', async () => { + const app = fakeApp(); + const chart = { cfg: { type: 'pie', x: 0, y: [1], series: null }, key: 'a:String|b:UInt64' }; + const hash = '#' + btoa(unescape(encodeURIComponent(JSON.stringify({ __asb: 1, sql: 'SELECT a, b FROM t', chart })))); + const env = fakeEnv({ location: { href: 'https://ch/sql' + hash, origin: 'https://ch', pathname: '/sql', search: '', hash } }); + await bootstrap(app, env); + expect(app.state.tabs[0].sql).toBe('SELECT a, b FROM t'); + expect(app.state.tabs[0].chartCfg).toEqual(chart.cfg); + expect(app.state.tabs[0].chartCfg).not.toBe(chart.cfg); // cloned, not aliased + expect(app.state.tabs[0].chartKey).toBe(chart.key); + }); + + it('restores a shared query (SQL + chart) from sessionStorage after the OAuth round-trip', async () => { // The hash is gone after the IdP redirect; the stash carries it through. const app = fakeApp({ token: valid, isSignedIn: () => true }); const env = fakeEnv({ location: { href: 'https://ch/sql', origin: 'https://ch', pathname: '/sql', search: '', hash: '' } }); - env.sessionStorage.setItem('oauth_shared_sql', 'SELECT 42'); + const chart = { cfg: { type: 'bar', x: 0, y: [1], series: null }, key: 'k' }; + env.sessionStorage.setItem('oauth_shared', JSON.stringify({ sql: 'SELECT 42', chart })); await bootstrap(app, env); expect(app.state.tabs[0].sql).toBe('SELECT 42'); expect(app.state.tabs[0].name).toBe('Shared query'); + expect(app.state.tabs[0].chartCfg).toEqual(chart.cfg); expect(app.renderApp).toHaveBeenCalled(); - expect(env.sessionStorage.getItem('oauth_shared_sql')).toBeNull(); // consumed on render + expect(env.sessionStorage.getItem('oauth_shared')).toBeNull(); // consumed on render + }); + + it('falls back to no shared query when the sessionStorage stash is corrupt', async () => { + const app = fakeApp({ token: valid, isSignedIn: () => true }); + const env = fakeEnv({ location: { href: 'https://ch/sql', origin: 'https://ch', pathname: '/sql', search: '', hash: '' } }); + env.sessionStorage.setItem('oauth_shared', '{not json'); + await bootstrap(app, env); + expect(app.state.tabs[0].sql).toBe(''); + expect(app.state.tabs[0].name).toBe('Untitled'); }); it('preserves extra query params while stripping oauth ones', async () => { diff --git a/tests/unit/results.test.js b/tests/unit/results.test.js index 96d1f7e..6203f0f 100644 --- a/tests/unit/results.test.js +++ b/tests/unit/results.test.js @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { renderResults, renderJson, renderTable, renderChart, colResizeWidth, openCellDetail } from '../../src/ui/results.js'; import { makeApp } from '../helpers/fake-app.js'; import { newResult } from '../../src/core/stream.js'; +import { schemaKey } from '../../src/core/chart-data.js'; const click = (el) => el.dispatchEvent(new Event('click', { bubbles: true })); @@ -300,34 +301,166 @@ describe('renderJson', () => { }); }); +// A result with two measures + two category columns, for multi-series/group-by. +function chartResult() { + const r = newResult('Table'); + r.columns = [ + { name: 'carrier', type: 'String' }, + { name: 'region', type: 'String' }, + { name: 'flights', type: 'UInt64' }, + { name: 'delay', type: 'Float64' }, + ]; + r.rows = [['B6', 'E', '10', '5.5'], ['AA', 'W', '20', '6.5']]; + r.progress = { rows: 2, bytes: 100, elapsed_ns: 5e6 }; + return r; +} +const fieldSel = (el, label) => [...el.querySelectorAll('.chart-field')] + .find((f) => f.querySelector('.chart-field-label').textContent === label).querySelector('select'); +const change = (sel, value) => { sel.value = value; sel.dispatchEvent(new Event('change', { bubbles: true })); }; + describe('renderChart', () => { - it('says so when no numeric column exists', () => { + it('shows a not-chartable hint when no measure exists', () => { const r = newResult('Table'); r.columns = [{ name: 'a', type: 'String' }]; r.rows = [['x']]; - expect(renderChart(r).textContent).toContain('No numeric columns to chart.'); + const app = appWithResult(r, { resultView: 'chart' }); + expect(renderChart(app, r).textContent).toContain('aren’t chartable'); }); - it('draws bars for numeric data', () => { - const r = tableResult(); - const el = renderChart(r); - expect(el.querySelectorAll('rect').length).toBeGreaterThan(0); - expect(el.querySelector('.chart-controls').textContent).toContain('X:'); + it('shows a "renders when complete" hint while the query is still running', () => { + const app = appWithResult(tableResult(), { resultView: 'chart', running: true }); + expect(renderChart(app, app.activeTab().result).textContent).toContain('renders when the query completes'); }); - it('handles an all-zero series (max 0)', () => { - const r = newResult('Table'); - r.columns = [{ name: 'k', type: 'String' }, { name: 'v', type: 'UInt64' }]; - r.rows = [['a', '0'], ['b', '0']]; - const el = renderChart(r); - expect(el.querySelectorAll('rect')).toHaveLength(2); + it('builds a config bar and instantiates Chart.js on a canvas (categorical → hbar default)', () => { + const app = appWithResult(tableResult(), { resultView: 'chart' }); + renderResults(app); + const view = app.dom.resultsRegion.querySelector('.chart-view'); + expect(view.querySelector('canvas')).not.toBeNull(); + expect(app.chart).not.toBeNull(); + expect(app.chart.config.type).toBe('bar'); // hbar maps to bar + indexAxis y + expect(app.chart.config.options.indexAxis).toBe('y'); + expect(app.activeTab().chartCfg).toMatchObject({ type: 'hbar', x: 1, y: [0] }); + }); + it('keeps a restored chart config when its schema key matches the result (saved/shared restore)', () => { + const r = chartResult(); + const app = appWithResult(r, { resultView: 'chart' }); + const tab = app.activeTab(); + tab.chartKey = schemaKey(r.columns); + tab.chartCfg = { type: 'pie', x: 0, y: [2], series: null }; // a deliberate non-default + renderResults(app); + expect(app.activeTab().chartCfg).toEqual({ type: 'pie', x: 0, y: [2], series: null }); // not re-derived + expect(app.chart.config.type).toBe('pie'); + }); + it('falls back to autoChart when a restored config does not fit the schema (hand-edited link)', () => { + const r = chartResult(); + const app = appWithResult(r, { resultView: 'chart' }); + const tab = app.activeTab(); + tab.chartKey = schemaKey(r.columns); + tab.chartCfg = { type: 'bar', x: 99, y: [1], series: null }; // x out of range + renderResults(app); + expect(app.activeTab().chartCfg.x).toBeLessThan(r.columns.length); // guard re-derived a safe default + expect(app.chart).not.toBeNull(); }); - it('samples + truncates long x labels for wide series', () => { + it('Type select switches renderer; non-pie keeps series, pie resets it to single-measure', () => { + const app = appWithResult(chartResult(), { resultView: 'chart' }); + renderResults(app); + // group-by first so we can prove pie clears it + change(fieldSel(app.dom.resultsRegion, 'Series'), '1'); + expect(app.activeTab().chartCfg.series).toBe(1); + change(fieldSel(app.dom.resultsRegion, 'Type'), 'line'); // non-pie branch + expect(app.activeTab().chartCfg.type).toBe('line'); + change(fieldSel(app.dom.resultsRegion, 'Type'), 'pie'); // pie branch resets series + expect(app.activeTab().chartCfg).toMatchObject({ type: 'pie', series: null }); + expect(fieldSel(app.dom.resultsRegion, 'Type')).not.toBeNull(); + expect([...app.dom.resultsRegion.querySelectorAll('.chart-field-label')].map((s) => s.textContent)) + .not.toContain('Series'); // series control hidden for pie + }); + it('X and Y selects update the per-tab config', () => { + const app = appWithResult(chartResult(), { resultView: 'chart' }); + renderResults(app); + change(fieldSel(app.dom.resultsRegion, 'X'), '1'); + expect(app.activeTab().chartCfg.x).toBe(1); + change(fieldSel(app.dom.resultsRegion, 'Y'), '3'); + expect(app.activeTab().chartCfg.y).toEqual([3]); + }); + it('"All measures" toggles between single and multi-series', () => { + const app = appWithResult(chartResult(), { resultView: 'chart' }); + renderResults(app); + const btn = () => [...app.dom.resultsRegion.querySelectorAll('.chart-toggle')][0]; + expect(btn().textContent).toBe('All measures'); + click(btn()); + expect(app.activeTab().chartCfg.y).toEqual([2, 3]); + expect(app.chart.config.data.datasets).toHaveLength(2); + expect(btn().textContent).toBe('Single series'); + click(btn()); + expect(app.activeTab().chartCfg.y).toEqual([2]); + }); + it('Series select sets and clears a group-by dimension', () => { + const app = appWithResult(chartResult(), { resultView: 'chart' }); + renderResults(app); + change(fieldSel(app.dom.resultsRegion, 'Series'), '1'); + expect(app.activeTab().chartCfg.series).toBe(1); + change(fieldSel(app.dom.resultsRegion, 'Series'), ''); + expect(app.activeTab().chartCfg.series).toBeNull(); + }); + it('notes the row cap when the result is larger than the chart shows', () => { const r = newResult('Table'); r.columns = [{ name: 'k', type: 'String' }, { name: 'v', type: 'UInt64' }]; - r.rows = Array.from({ length: 30 }, (_, i) => ['a_very_long_label_' + i, String(i)]); - const el = renderChart(r); - // fewer text labels than rows (sampled every Nth) — y-axis adds 2 texts - expect(el.querySelectorAll('text').length).toBeLessThan(30); - // long labels are truncated with an ellipsis - expect([...el.querySelectorAll('text')].some((t) => t.textContent.endsWith('…'))).toBe(true); + r.rows = Array.from({ length: 600 }, (_, i) => ['k' + i, String(i)]); + r.progress = { rows: 600, bytes: 100, elapsed_ns: 5e6 }; + const app = appWithResult(r, { resultView: 'chart' }); + renderResults(app); + const note = app.dom.resultsRegion.querySelector('.chart-cap-note'); + expect(note).not.toBeNull(); + expect(note.textContent).toContain('first 500 of'); + // a small result shows no cap note + const small = appWithResult(tableResult(), { resultView: 'chart' }); + renderResults(small); + expect(small.dom.resultsRegion.querySelector('.chart-cap-note')).toBeNull(); + }); + it('destroys the previous Chart instance on re-render, and re-derives config on a new schema', () => { + const app = appWithResult(chartResult(), { resultView: 'chart' }); + renderResults(app); + const first = app.chart; + const cfg = app.activeTab().chartCfg; + renderResults(app); // stable schema → keep config, swap chart instance + expect(first.destroyed).toBe(true); + expect(app.chart).not.toBe(first); + expect(app.activeTab().chartCfg).toBe(cfg); + app.activeTab().result = tableResult(); // different schema → re-derive + renderResults(app); + expect(app.activeTab().chartCfg).not.toBe(cfg); + }); + it('does not re-derive (clobber) a restored config while the query is still running', () => { + // running + rows already streamed: the run-state guard must fire BEFORE + // chartCfgFor, so a still-settling result can't stamp a new key / autoChart + // over the restored saved/shared config. + const app = appWithResult(chartResult(), { resultView: 'chart', running: true }); + const tab = app.activeTab(); + const restored = { type: 'pie', x: 0, y: [2], series: null }; + tab.chartCfg = restored; + tab.chartKey = 'STALE_KEY'; // deliberately != schemaKey(result.columns) + renderResults(app); + expect(app.dom.resultsRegion.textContent).toContain('renders when the query completes'); + expect(tab.chartCfg).toBe(restored); // untouched — chartCfgFor never ran + expect(tab.chartKey).toBe('STALE_KEY'); + }); + it('normalizes a restored, self-contradictory pie config (multi-measure + series) on render', () => { + const r = chartResult(); + const app = appWithResult(r, { resultView: 'chart' }); + const tab = app.activeTab(); + tab.chartKey = schemaKey(r.columns); // in-range but invalid combination + tab.chartCfg = { type: 'pie', x: 0, y: [2, 3], series: 1 }; + renderResults(app); + expect(app.activeTab().chartCfg).toEqual({ type: 'pie', x: 0, y: [2], series: null }); + expect(app.chart.config.data.datasets).toHaveLength(1); // single pie dataset + }); + it('clears the series when the X column is changed to equal it', () => { + const app = appWithResult(chartResult(), { resultView: 'chart' }); + renderResults(app); + change(fieldSel(app.dom.resultsRegion, 'Series'), '1'); // series = region(1) + expect(app.activeTab().chartCfg.series).toBe(1); + change(fieldSel(app.dom.resultsRegion, 'X'), '1'); // X now equals series → series cleared + expect(app.activeTab().chartCfg.x).toBe(1); + expect(app.activeTab().chartCfg.series).toBeNull(); }); }); diff --git a/tests/unit/saved-history.test.js b/tests/unit/saved-history.test.js index f1a5453..b8a79cf 100644 --- a/tests/unit/saved-history.test.js +++ b/tests/unit/saved-history.test.js @@ -23,12 +23,15 @@ describe('renderSavedHistory', () => { it('saved: lists rows, loads on click, deletes via trash + refreshes Save button', () => { const app = makeApp(); app.state.sidePanel = 'saved'; - app.state.savedQueries = [{ id: 's1', name: 'Q1', sql: 'SELECT 1\n-- more', favorite: false }]; + const chart = { cfg: { type: 'pie', x: 0, y: [1], series: null }, key: 'k' }; + app.state.savedQueries = [{ id: 's1', name: 'Q1', sql: 'SELECT 1\n-- more', favorite: false, chart, view: 'chart' }]; renderSavedHistory(app); const row = app.dom.savedList.querySelector('.saved-row'); expect(row.querySelector('.preview').textContent).toBe('SELECT 1'); click(row); - expect(app.actions.loadIntoNewTab).toHaveBeenCalledWith('Q1', 'SELECT 1\n-- more', 's1'); // links the tab + // links the tab + restores the chart, then runs in the saved view so results show immediately + expect(app.actions.loadIntoNewTab).toHaveBeenCalledWith('Q1', 'SELECT 1\n-- more', 's1', chart); + expect(app.actions.run).toHaveBeenCalledWith({ view: 'chart' }); byTitle(row, 'Delete').dispatchEvent(new Event('click', { bubbles: true })); expect(app.state.savedQueries).toHaveLength(0); expect(app.updateSaveBtn).toHaveBeenCalled(); @@ -127,6 +130,7 @@ describe('renderSavedHistory', () => { expect(rows[1].textContent).not.toContain('rows'); click(rows[0]); expect(app.actions.loadIntoNewTab).toHaveBeenCalledWith('From history', 'SELECT 1'); + expect(app.actions.run).toHaveBeenCalled(); // re-runs on restore }); it('history: per-row delete removes just that entry without loading it', () => { diff --git a/tests/unit/saved-io.test.js b/tests/unit/saved-io.test.js index 9236cdd..fe6c468 100644 --- a/tests/unit/saved-io.test.js +++ b/tests/unit/saved-io.test.js @@ -14,6 +14,19 @@ describe('buildExportDoc', () => { it('handles an empty list', () => { expect(buildExportDoc([], 'T').queries).toEqual([]); }); + it('carries optional chart + view, omitting them when absent or invalid', () => { + const chart = { cfg: { type: 'pie', x: 0, y: [1], series: null }, key: 'k' }; + const doc = buildExportDoc([ + { id: 's1', name: 'A', sql: '1', favorite: false, chart, view: 'chart' }, + { id: 's2', name: 'B', sql: '2', favorite: false, view: 'bogus' }, // invalid view dropped + { id: 's3', name: 'C', sql: '3', favorite: false }, + ], 'T'); + expect(doc.queries[0].chart).toEqual(chart); + expect(doc.queries[0].view).toBe('chart'); + expect('view' in doc.queries[1]).toBe(false); + expect('chart' in doc.queries[2]).toBe(false); + expect('view' in doc.queries[2]).toBe(false); + }); }); describe('parseImportDoc', () => { @@ -30,6 +43,25 @@ describe('parseImportDoc', () => { { id: undefined, name: 'B', sql: 'SELECT 2', favorite: false }, ]); }); + it('keeps a valid chart payload and drops a malformed one', () => { + const chart = { cfg: { type: 'bar', x: 0, y: [1], series: null }, key: 'k' }; + const { queries } = parseImportDoc(env({ queries: [ + { name: 'A', sql: '1', chart }, + { name: 'B', sql: '2', chart: { nope: true } }, // no cfg → dropped + { name: 'C', sql: '3', chart: 'x' }, // non-object → dropped + ] })); + expect(queries[0].chart).toEqual(chart); + expect(queries[1].chart).toBeUndefined(); + expect(queries[2].chart).toBeUndefined(); + }); + it('keeps a known view and drops an unknown one', () => { + const { queries } = parseImportDoc(env({ queries: [ + { name: 'A', sql: '1', view: 'json' }, + { name: 'B', sql: '2', view: 'wat' }, // not a known view → dropped + ] })); + expect(queries[0].view).toBe('json'); + expect(queries[1].view).toBeUndefined(); + }); it('throws a user message for each invalid envelope', () => { expect(() => parseImportDoc('{not json')).toThrow('Not a valid JSON file'); expect(() => parseImportDoc(JSON.stringify({ format: 'other' }))).toThrow('Unrecognized file format'); @@ -59,4 +91,24 @@ describe('mergeSaved', () => { expect(r.merged.map((q) => q.name)).toEqual(['A2', 'B', 'C']); expect(existing[0]).toEqual({ id: 's1', name: 'A', sql: '1', favorite: false }); // not mutated }); + it('carries chart on add, replaces it by id, and drops it when an update omits it', () => { + const chart = { cfg: { type: 'pie', x: 0, y: [1], series: null }, key: 'k' }; + const chart2 = { cfg: { type: 'line', x: 0, y: [1], series: null }, key: 'k' }; + const existing = [ + { id: 's1', name: 'A', sql: '1', favorite: false, chart }, + { id: 's2', name: 'B', sql: '2', favorite: false, chart }, + ]; + const incoming = [ + { id: 's1', name: 'A2', sql: '1b', favorite: false }, // no chart/view → drop + { id: 's2', name: 'B2', sql: '2b', favorite: false, chart: chart2, view: 'json' }, // replace + { name: 'C', sql: '3', favorite: false, chart, view: 'chart' }, // add with chart+view + ]; + const r = mergeSaved(existing, incoming, () => 'g'); + expect(r.merged.find((q) => q.id === 's1').chart).toBeUndefined(); + expect(r.merged.find((q) => q.id === 's1').view).toBeUndefined(); + expect(r.merged.find((q) => q.id === 's2').chart).toEqual(chart2); + expect(r.merged.find((q) => q.id === 's2').view).toBe('json'); + expect(r.merged.find((q) => q.name === 'C').chart).toEqual(chart); + expect(r.merged.find((q) => q.name === 'C').view).toBe('chart'); + }); }); diff --git a/tests/unit/share.test.js b/tests/unit/share.test.js index 03624df..b395417 100644 --- a/tests/unit/share.test.js +++ b/tests/unit/share.test.js @@ -1,24 +1,43 @@ import { describe, it, expect } from 'vitest'; -import { encodeSqlForHash, decodeSqlFromHash } from '../../src/core/share.js'; +import { encodeShare, decodeShare } from '../../src/core/share.js'; describe('share encode/decode', () => { - it('round-trips ASCII SQL', () => { + it('round-trips ASCII SQL (no chart → chart null)', () => { const sql = 'SELECT * FROM t WHERE x = 1'; - expect(decodeSqlFromHash('#' + encodeSqlForHash(sql))).toBe(sql); + expect(decodeShare('#' + encodeShare(sql))).toEqual({ sql, chart: null }); }); it('round-trips unicode', () => { const sql = 'SELECT \'café — 日本語\''; - expect(decodeSqlFromHash(encodeSqlForHash(sql))).toBe(sql); + expect(decodeShare(encodeShare(sql))).toEqual({ sql, chart: null }); + }); + it('round-trips a chart payload alongside the SQL', () => { + const sql = 'SELECT a, b FROM t'; + const chart = { cfg: { type: 'pie', x: 0, y: [1], series: null }, key: 'a:String|b:UInt64' }; + expect(decodeShare(encodeShare(sql, chart))).toEqual({ sql, chart }); + }); + it('ignores a chart with no cfg (encodes as legacy SQL)', () => { + const sql = 'SELECT 1'; + expect(decodeShare(encodeShare(sql, { key: 'x' }))).toEqual({ sql, chart: null }); + }); + it('drops a non-object chart field in a tagged envelope', () => { + // hand-built tagged envelope whose chart is a string, not an object + const hash = btoa(unescape(encodeURIComponent(JSON.stringify({ __asb: 1, sql: 'SELECT 2', chart: 'nope' })))); + expect(decodeShare(hash)).toEqual({ sql: 'SELECT 2', chart: null }); }); it('tolerates a leading # or none', () => { - const enc = encodeSqlForHash('SELECT 1'); - expect(decodeSqlFromHash(enc)).toBe('SELECT 1'); - expect(decodeSqlFromHash('#' + enc)).toBe('SELECT 1'); + const enc = encodeShare('SELECT 1'); + expect(decodeShare(enc).sql).toBe('SELECT 1'); + expect(decodeShare('#' + enc).sql).toBe('SELECT 1'); + }); + it('treats valid-JSON-but-untagged decoded text as legacy SQL', () => { + // base64 of the literal text "123" → JSON.parse succeeds (number), not tagged + const hash = btoa('123'); + expect(decodeShare(hash)).toEqual({ sql: '123', chart: null }); }); it('returns empty for empty/short/garbage hashes', () => { - expect(decodeSqlFromHash('')).toBe(''); - expect(decodeSqlFromHash('#')).toBe(''); - expect(decodeSqlFromHash(null)).toBe(''); - expect(decodeSqlFromHash('#@@@@')).toBe(''); + expect(decodeShare('')).toEqual({ sql: '', chart: null }); + expect(decodeShare('#')).toEqual({ sql: '', chart: null }); + expect(decodeShare(null)).toEqual({ sql: '', chart: null }); + expect(decodeShare('#@@@@')).toEqual({ sql: '', chart: null }); }); }); diff --git a/tests/unit/state.test.js b/tests/unit/state.test.js index 65b2141..3994b22 100644 --- a/tests/unit/state.test.js +++ b/tests/unit/state.test.js @@ -2,7 +2,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { KEYS, newTabObj, createState, activeTab, allocTabId, saveQuery, savedForTab, renameSaved, toggleFavorite, sortedSaved, importSaved, - deleteSaved, recordHistory, clearHistory, deleteHistory, + deleteSaved, recordHistory, clearHistory, deleteHistory, tabChart, } from '../../src/state.js'; afterEach(() => vi.unstubAllGlobals()); @@ -19,7 +19,7 @@ const reader = (over = {}) => ({ describe('newTabObj', () => { it('creates a blank tab', () => { - expect(newTabObj('t9')).toEqual({ id: 't9', name: 'Untitled', sql: '', dirty: false, result: null, savedId: null }); + expect(newTabObj('t9')).toEqual({ id: 't9', name: 'Untitled', sql: '', dirty: false, result: null, savedId: null, chartCfg: null, chartKey: null }); }); }); @@ -159,6 +159,49 @@ describe('saved queries', () => { importSaved(s, [{ name: 'Z', sql: 'zz' }]); expect(s.savedQueries.find((q) => q.name === 'Z').id).toMatch(/^s/); }); + it('tabChart packs a tab chart config (or null), defaulting a missing key', () => { + expect(tabChart(null)).toBeNull(); + expect(tabChart({ chartCfg: null })).toBeNull(); + const cfg = { type: 'bar', x: 0, y: [1], series: null }; + expect(tabChart({ chartCfg: cfg, chartKey: 'k' })).toEqual({ cfg, key: 'k' }); + expect(tabChart({ chartCfg: cfg })).toEqual({ cfg, key: null }); // key ?? null + }); + it('saveQuery persists, updates, and clears the chart config alongside the SQL', () => { + const s = createState(reader()); + const save = vi.fn(); + const tab = s.tabs[0]; + tab.sql = 'SELECT a, b'; + tab.chartCfg = { type: 'pie', x: 0, y: [1], series: null }; + tab.chartKey = 'a:String|b:UInt64'; + const e1 = saveQuery(s, tab, 'Chartd', save, 100); + expect(e1.chart).toEqual({ cfg: tab.chartCfg, key: tab.chartKey }); + expect(e1.chart.cfg).not.toBe(tab.chartCfg); // cloned into the entry + // re-save with a different chart → entry.chart updates in place + tab.chartCfg = { type: 'line', x: 0, y: [1], series: null }; + saveQuery(s, tab, 'Chartd', save, 200); + expect(s.savedQueries[0].chart.cfg.type).toBe('line'); + // re-save after the chart is cleared → entry.chart is dropped + tab.chartCfg = null; + saveQuery(s, tab, 'Chartd', save, 300); + expect(s.savedQueries[0].chart).toBeUndefined(); + }); + it('saveQuery persists the result view (Table/JSON/Chart), updates it, and ignores the transient raw view', () => { + const s = createState(reader()); + const save = vi.fn(); + const tab = s.tabs[0]; + tab.sql = 'SELECT 1'; + s.resultView = 'chart'; + const e = saveQuery(s, tab, 'V', save, 100); + expect(e.view).toBe('chart'); + // re-save under a different view → updates + s.resultView = 'json'; + saveQuery(s, tab, 'V', save, 200); + expect(s.savedQueries[0].view).toBe('json'); + // raw view (TSV/JSON output) is not a saved view → dropped + s.resultView = 'raw'; + saveQuery(s, tab, 'V', save, 300); + expect(s.savedQueries[0].view).toBeUndefined(); + }); it('deleteSaved removes + clears tab pointers', () => { const s = createState(reader()); s.savedQueries = [{ id: 's1', sql: 'x', name: 'n' }]; diff --git a/tests/unit/tabs.test.js b/tests/unit/tabs.test.js index 02279a1..afd8657 100644 --- a/tests/unit/tabs.test.js +++ b/tests/unit/tabs.test.js @@ -78,8 +78,18 @@ describe('newTab / loadIntoNewTab', () => { app.dom.editorTextarea = { focus: vi.fn() }; loadIntoNewTab(app, 'Saved', 'SELECT 1', 's1'); expect(app.activeTab()).toMatchObject({ name: 'Saved', sql: 'SELECT 1', savedId: 's1' }); + expect(app.activeTab().chartCfg).toBeNull(); // no chart payload → stays null expect(app.dom.editorTextarea.focus).toHaveBeenCalled(); }); + it('loadIntoNewTab restores a chart payload (cfg cloned, key set)', () => { + const app = makeApp(); + const chart = { cfg: { type: 'pie', x: 0, y: [1], series: null }, key: 'a:String|b:UInt64' }; + loadIntoNewTab(app, 'Saved', 'SELECT 1', 's1', chart); + const tab = app.activeTab(); + expect(tab.chartCfg).toEqual(chart.cfg); + expect(tab.chartCfg).not.toBe(chart.cfg); // cloned, not aliased into the saved entry + expect(tab.chartKey).toBe(chart.key); + }); it('loadIntoNewTab defaults the name and leaves savedId null (history restore)', () => { const app = makeApp(); loadIntoNewTab(app, '', 'SELECT 2'); diff --git a/tests/vitest.config.ts b/tests/vitest.config.ts index 380d9eb..14f6817 100644 --- a/tests/vitest.config.ts +++ b/tests/vitest.config.ts @@ -19,6 +19,16 @@ export default defineConfig({ environment: 'happy-dom', include: ['tests/unit/**/*.test.js'], setupFiles: ['tests/setup.js'], + // Run workers as threads, not child processes. Vitest 2.x defaults to + // `pool: 'forks'`, which fans out to (cpus-1) child *node processes* via + // tinypool; on normal exit those should be reaped, but a detached swarm can + // survive a run and pile up across runs until it pins the machine. Threads + // live inside the single vitest process, so they die with the parent and + // can never become orphaned OS processes. The suite is pure ES modules + + // happy-dom (no native deps), so threads are safe. Cap parallelism so one + // run can't peg every core. + pool: 'threads', + poolOptions: { threads: { maxThreads: 4, minThreads: 1 } }, coverage: { provider: 'v8', reporter: ['text', 'html', 'lcov'],