diff --git a/.github/workflows/explorer-e2e.yml b/.github/workflows/explorer-e2e.yml index c2288fa4..d9f596c6 100644 --- a/.github/workflows/explorer-e2e.yml +++ b/.github/workflows/explorer-e2e.yml @@ -102,6 +102,13 @@ jobs: - name: Install node deps run: npm install + # Fast pure-logic gate (#249 PR3): unit-tests the ES modules extracted + # from explorer.qmd (assets/js/sql-builders.js + explorer-utils.js). + # Node built-ins only, sub-second — runs before the slow Quarto/ + # Playwright steps so a module regression fails fast. + - name: Unit tests (extracted modules) + run: node --test tests/unit/*.test.mjs + - name: Resolve Playwright version id: pw run: echo "version=$(node -e 'console.log(require("@playwright/test/package.json").version)')" >> "$GITHUB_OUTPUT" diff --git a/_quarto.yml b/_quarto.yml index 685a07f9..c6d00354 100644 --- a/_quarto.yml +++ b/_quarto.yml @@ -3,6 +3,8 @@ project: output-dir: docs resources: - assets/js/source-palette.js + - assets/js/sql-builders.js + - assets/js/explorer-utils.js website: title: "iSamples" diff --git a/assets/js/explorer-utils.js b/assets/js/explorer-utils.js new file mode 100644 index 00000000..4e9440b4 --- /dev/null +++ b/assets/js/explorer-utils.js @@ -0,0 +1,66 @@ +// Pure helpers extracted from explorer.qmd (issue #249, PR3). +// No closure over `viewer`/`db`/DOM — safe to unit-test under Node and to +// import into the Interactive Explorer's OJS runtime (see explorer.qmd). + +// HTML-escape a value for safe interpolation into innerHTML. +export function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// Split a free-text query into whitespace-delimited terms (no empties). +export function searchTerms(value) { + return String(value || '').trim().split(/\s+/).filter(Boolean); +} + +// Parse a numeric URL param with a default and optional clamping. +export function parseNum(val, def, min, max) { + if (val == null) return def; + const n = parseFloat(val); + if (!Number.isFinite(n)) return def; + if (min != null && n < min) return min; + if (max != null && n > max) return max; + return n; +} + +// Read a comma-separated URLSearchParams value into an array of trimmed, +// non-empty strings. Returns null when the key is absent, [] when present +// but empty. +export function csvParamValues(params, key) { + if (!params.has(key)) return null; + const raw = params.get(key) || ''; + if (raw.trim() === '') return []; + return raw.split(',').map(s => s.trim()).filter(Boolean); +} + +// Resolve a pid to its canonical resolver URL. All iSamples sources resolve +// via n2t.net: ARK pids (OpenContext, GEOME, Smithsonian) and IGSN pids +// (SESAR) alike. +export function sourceUrl(pid) { + if (!pid) return null; + return `https://n2t.net/${pid}`; +} + +// Decode the explorer globe state from a URL hash fragment. +// `hashStr` defaults to `location.hash` for in-browser callers (every current +// call site is zero-arg); tests pass an explicit string so `location` is never +// referenced. Numeric fields are clamped to valid geographic / altitude ranges. +export function readHash(hashStr = location.hash) { + const params = new URLSearchParams(hashStr.slice(1)); + return { + v: parseInt(params.get('v')) || 0, + lat: parseNum(params.get('lat'), null, -90, 90), + lng: parseNum(params.get('lng'), null, -180, 180), + alt: parseNum(params.get('alt'), null, 100, 40000000), + heading: parseNum(params.get('heading'), 0, 0, 360), + pitch: parseNum(params.get('pitch'), -90, -90, 0), + mode: params.get('mode') || null, + pid: params.get('pid') || null, + h3: params.get('h3') || null, + heatmap: params.get('heatmap') === '1', + }; +} diff --git a/assets/js/sql-builders.js b/assets/js/sql-builders.js new file mode 100644 index 00000000..d4c1b50b --- /dev/null +++ b/assets/js/sql-builders.js @@ -0,0 +1,41 @@ +// Pure SQL-string builders extracted from explorer.qmd (issue #249, PR3). +// No closure over `viewer`/`db`/DOM — safe to unit-test under Node and to +// import into the Interactive Explorer's OJS runtime (see explorer.qmd). +// +// Internal dependency chain: textSearch* -> escapeIlikePattern -> escSql. +// Each function references the module-local sibling directly, so importing +// these into OJS does NOT create reactive edges between the bound cells. + +// Double single-quotes for safe interpolation into a SQL string literal. +export function escSql(value) { + return String(value).replace(/'/g, "''"); +} + +// Escape a value for use inside an ILIKE '%...%' pattern with ESCAPE '\'. +// First SQL-escapes single quotes (escSql), then backslash-escapes the LIKE +// metacharacters \ % _ so they match literally. +export function escapeIlikePattern(value) { + return escSql(value).replace(/[\\%_]/g, "\\$&"); +} + +// Build a WHERE fragment: every term must match at least one column +// (terms AND-ed, columns OR-ed within a term). +export function textSearchWhere(terms, columns) { + return terms.map(raw => { + const term = escapeIlikePattern(raw); + const checks = columns.map(col => `${col} ILIKE '%${term}%' ESCAPE '\\'`); + return `(${checks.join(' OR ')})`; + }).join(' AND '); +} + +// Build a relevance-score expression: sum of per-term, per-weighted-column +// CASE contributions. Returns '0' when there are no terms. +export function textSearchScore(terms, weightedColumns) { + if (!terms.length) return '0'; + return terms.map(raw => { + const term = escapeIlikePattern(raw); + return weightedColumns.map(({ col, weight }) => + `CASE WHEN ${col} ILIKE '%${term}%' ESCAPE '\\' THEN ${weight} ELSE 0 END` + ).join(' + '); + }).map(score => `(${score})`).join(' + '); +} diff --git a/explorer.qmd b/explorer.qmd index 08b7feaa..d6719724 100644 --- a/explorer.qmd +++ b/explorer.qmd @@ -774,14 +774,25 @@ _palette = await import(new URL('assets/js/source-palette.js', document.baseURI) SOURCE_COLORS = _palette.SOURCE_COLORS SOURCE_NAMES = _palette.SOURCE_NAMES -// === Source URL: resolve pid to original repository === -function sourceUrl(pid) { - if (!pid) return null; - // All sources resolve via n2t.net: - // ARK pids (OpenContext, GEOME, Smithsonian) → n2t.net/ark:/... - // IGSN pids (SESAR) → n2t.net/IGSN:... - return `https://n2t.net/${pid}`; -} +// === Pure logic extracted to ES modules (issue #249, PR3) === +// Same path-relative dynamic-import pattern as the palette above, so these +// resolve under both the custom domain (isamples.org) and fork project-pages +// previews. The functions are closure-free (no viewer/db/DOM) and unit-tested +// under Node (tests/unit/). Each `name = _module.fn` line is a single OJS cell +// that REPLACES the former inline `function name(){...}` cell. +_sqlBuilders = await import(new URL('assets/js/sql-builders.js', document.baseURI).href) +escSql = _sqlBuilders.escSql +escapeIlikePattern = _sqlBuilders.escapeIlikePattern +textSearchWhere = _sqlBuilders.textSearchWhere +textSearchScore = _sqlBuilders.textSearchScore + +_explorerUtils = await import(new URL('assets/js/explorer-utils.js', document.baseURI).href) +escapeHtml = _explorerUtils.escapeHtml +searchTerms = _explorerUtils.searchTerms +parseNum = _explorerUtils.parseNum +csvParamValues = _explorerUtils.csvParamValues +sourceUrl = _explorerUtils.sourceUrl +readHash = _explorerUtils.readHash // === Source Filter: get active sources and build SQL clause === function getActiveSources() { @@ -821,13 +832,6 @@ DEFAULT_POINT_BUDGET = 5000 // long range while bypassing terrain occlusion at every interactive zoom. POINT_DEPTH_TEST_DISTANCE = 2.0e6 -function csvParamValues(params, key) { - if (!params.has(key)) return null; - const raw = params.get(key) || ''; - if (raw.trim() === '') return []; - return raw.split(',').map(s => s.trim()).filter(Boolean); -} - function updateSourceLegendState() { document.querySelectorAll('#sourceFilter .legend-item').forEach(li => { const cb = li.querySelector('input'); @@ -940,32 +944,6 @@ function writeQueryState(opts = {}) { } } -function searchTerms(value) { - return String(value || '').trim().split(/\s+/).filter(Boolean); -} - -function escapeIlikePattern(value) { - return escSql(value).replace(/[\\%_]/g, "\\$&"); -} - -function textSearchWhere(terms, columns) { - return terms.map(raw => { - const term = escapeIlikePattern(raw); - const checks = columns.map(col => `${col} ILIKE '%${term}%' ESCAPE '\\'`); - return `(${checks.join(' OR ')})`; - }).join(' AND '); -} - -function textSearchScore(terms, weightedColumns) { - if (!terms.length) return '0'; - return terms.map(raw => { - const term = escapeIlikePattern(raw); - return weightedColumns.map(({ col, weight }) => - `CASE WHEN ${col} ILIKE '%${term}%' ESCAPE '\\' THEN ${weight} ELSE 0 END` - ).join(' + '); - }).map(score => `(${score})`).join(' + '); -} - // === Material / Sampled Feature / Specimen Type Filters === // Checkbox semantics: start UNCHECKED (no filter; show everything). User // checks items to *include only those*. Empty = no filter. Matches the @@ -1009,10 +987,6 @@ function syncFacetNote() { el.style.display = (active && inCluster && !heatOn) ? 'block' : 'none'; } -function escSql(value) { - return String(value).replace(/'/g, "''"); -} - // Returns a portable predicate fragment (no outer-table alias dependency) // that callers append to a WHERE: ` AND ${facetFilterSQL()}`. Uses a // `pid IN (SELECT pid FROM facets WHERE ...)` subquery so it works @@ -1186,31 +1160,7 @@ function markFacetCountsRecomputing() { } // === URL State: encode/decode globe state in hash fragment === -function parseNum(val, def, min, max) { - if (val == null) return def; - const n = parseFloat(val); - if (!Number.isFinite(n)) return def; - if (min != null && n < min) return min; - if (max != null && n > max) return max; - return n; -} - -function readHash() { - const params = new URLSearchParams(location.hash.slice(1)); - return { - v: parseInt(params.get('v')) || 0, - lat: parseNum(params.get('lat'), null, -90, 90), - lng: parseNum(params.get('lng'), null, -180, 180), - alt: parseNum(params.get('alt'), null, 100, 40000000), - heading: parseNum(params.get('heading'), 0, 0, 360), - pitch: parseNum(params.get('pitch'), -90, -90, 0), - mode: params.get('mode') || null, - pid: params.get('pid') || null, - h3: params.get('h3') || null, - heatmap: params.get('heatmap') === '1', - }; -} - +// (decode side — parseNum + readHash — extracted to assets/js/explorer-utils.js, PR3) function buildHash(v) { const cam = v.camera; const carto = cam.positionCartographic; @@ -1518,15 +1468,6 @@ function updateSamples(samples) { // ORDER BY pid makes "Page N is the same N rows" actually true. TABLE_PAGE_SIZE = 100 -function escapeHtml(value) { - return String(value ?? '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - ``` ```{ojs} diff --git a/package.json b/package.json index b92e58be..8ec9f2ec 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "private": true, "scripts": { "test": "playwright test", + "test:unit": "node --test tests/unit/*.test.mjs", "test:headed": "playwright test --headed", "test:ui": "playwright test --ui", "test:debug": "playwright test --debug", diff --git a/tests/unit/explorer-utils.test.mjs b/tests/unit/explorer-utils.test.mjs new file mode 100644 index 00000000..2f4882f2 --- /dev/null +++ b/tests/unit/explorer-utils.test.mjs @@ -0,0 +1,70 @@ +// Unit tests for assets/js/explorer-utils.js (issue #249, PR3). +// Run: node --test tests/unit/ (Node built-ins only, no install) +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + escapeHtml, searchTerms, parseNum, csvParamValues, sourceUrl, readHash, +} from '../../assets/js/explorer-utils.js'; + +test('escapeHtml escapes the five HTML-significant chars; nullish -> ""', () => { + assert.equal( + escapeHtml(`O'B & C`), + '<a href="x">O'B & C</a>' + ); + assert.equal(escapeHtml(null), ''); + assert.equal(escapeHtml(undefined), ''); + assert.equal(escapeHtml(0), '0'); +}); + +test('searchTerms splits on whitespace, drops empties', () => { + assert.deepEqual(searchTerms(' hello world '), ['hello', 'world']); + assert.deepEqual(searchTerms(''), []); + assert.deepEqual(searchTerms(null), []); + assert.deepEqual(searchTerms('one'), ['one']); +}); + +test('parseNum: default for nullish/non-finite, clamps to [min,max]', () => { + assert.equal(parseNum(null, 5), 5); + assert.equal(parseNum(undefined, 7), 7); + assert.equal(parseNum('abc', 5), 5); + assert.equal(parseNum('45', 0, 0, 90), 45); + assert.equal(parseNum('100', 0, 0, 90), 90); // clamp max + assert.equal(parseNum('-5', 0, 0, 90), 0); // clamp min + assert.equal(parseNum('3.5', 0), 3.5); +}); + +test('csvParamValues: null when absent, [] when empty, trimmed non-empty list', () => { + assert.equal(csvParamValues(new URLSearchParams(''), 'x'), null); + assert.deepEqual(csvParamValues(new URLSearchParams('x='), 'x'), []); + assert.deepEqual(csvParamValues(new URLSearchParams('x=a,b, c ,'), 'x'), ['a', 'b', 'c']); +}); + +test('sourceUrl: n2t.net resolver; null for falsy', () => { + assert.equal(sourceUrl('ark:/28722/k2x'), 'https://n2t.net/ark:/28722/k2x'); + assert.equal(sourceUrl('IGSN:ABC123'), 'https://n2t.net/IGSN:ABC123'); + assert.equal(sourceUrl(''), null); + assert.equal(sourceUrl(null), null); +}); + +test('readHash: full round-trip parse', () => { + assert.deepEqual( + readHash('#v=1&lat=10&lng=20&alt=500&heading=45&pitch=-30&mode=point&pid=abc&h3=8a&heatmap=1'), + { v: 1, lat: 10, lng: 20, alt: 500, heading: 45, pitch: -30, mode: 'point', pid: 'abc', h3: '8a', heatmap: true } + ); +}); + +test('readHash: empty hash -> defaults', () => { + assert.deepEqual( + readHash(''), + { v: 0, lat: null, lng: null, alt: null, heading: 0, pitch: -90, mode: null, pid: null, h3: null, heatmap: false } + ); +}); + +test('readHash: clamps lat/lng/alt and treats heatmap!=1 as false', () => { + const h = readHash('#lat=999&lng=-999&alt=50&heatmap=0'); + assert.equal(h.lat, 90); // clamp to +90 + assert.equal(h.lng, -180); // clamp to -180 + assert.equal(h.alt, 100); // clamp to min 100 + assert.equal(h.heading, 0); // default + assert.equal(h.heatmap, false); +}); diff --git a/tests/unit/sql-builders.test.mjs b/tests/unit/sql-builders.test.mjs new file mode 100644 index 00000000..003e4770 --- /dev/null +++ b/tests/unit/sql-builders.test.mjs @@ -0,0 +1,50 @@ +// Unit tests for assets/js/sql-builders.js (issue #249, PR3). +// Run: node --test tests/unit/ (Node built-ins only, no install) +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + escSql, escapeIlikePattern, textSearchWhere, textSearchScore, +} from '../../assets/js/sql-builders.js'; + +test('escSql doubles single quotes and stringifies', () => { + assert.equal(escSql("O'Brien"), "O''Brien"); + assert.equal(escSql("plain"), "plain"); + assert.equal(escSql("''"), "''''"); + assert.equal(escSql(123), "123"); +}); + +test('escapeIlikePattern backslash-escapes LIKE metachars after SQL-escaping', () => { + assert.equal(escapeIlikePattern("100%"), "100\\%"); + assert.equal(escapeIlikePattern("a_b"), "a\\_b"); + // a single backslash becomes an escaped backslash + assert.equal(escapeIlikePattern("a\\b"), "a\\\\b"); + // single-quote doubling (escSql) happens BEFORE metachar escaping + assert.equal(escapeIlikePattern("O'Brien%"), "O''Brien\\%"); + assert.equal(escapeIlikePattern("plain"), "plain"); +}); + +test('textSearchWhere: terms AND-ed, columns OR-ed, ESCAPE clause present', () => { + assert.equal( + textSearchWhere(['cat'], ['label', 'descr']), + "(label ILIKE '%cat%' ESCAPE '\\' OR descr ILIKE '%cat%' ESCAPE '\\')" + ); + assert.equal( + textSearchWhere(['cat', 'dog'], ['label']), + "(label ILIKE '%cat%' ESCAPE '\\') AND (label ILIKE '%dog%' ESCAPE '\\')" + ); +}); + +test('textSearchScore: empty terms -> "0"; weighted CASE sum otherwise', () => { + assert.equal(textSearchScore([], [{ col: 'label', weight: 3 }]), '0'); + assert.equal( + textSearchScore(['cat'], [{ col: 'label', weight: 3 }, { col: 'descr', weight: 1 }]), + "(CASE WHEN label ILIKE '%cat%' ESCAPE '\\' THEN 3 ELSE 0 END + CASE WHEN descr ILIKE '%cat%' ESCAPE '\\' THEN 1 ELSE 0 END)" + ); +}); + +test('textSearchScore escapes terms (injection-safe)', () => { + assert.equal( + textSearchScore(["O'Brien"], [{ col: 'label', weight: 1 }]), + "(CASE WHEN label ILIKE '%O''Brien%' ESCAPE '\\' THEN 1 ELSE 0 END)" + ); +});