From 14b6c09e3f5843d7f8688874109d36ea84dfa153 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 4 Nov 2025 11:55:03 +0000 Subject: [PATCH] feat: Add failure mode tests and fix WASM smoke test panic This commit introduces a suite of tests for failure modes in the k-d tree library and resolves a persistent panic in the WebAssembly (WASM) smoke test. Key changes: - Adds `kdtree_failure_test.go` to test error conditions like empty inputs, dimension mismatches, and duplicate IDs. - Fixes a `panic: ValueOf: invalid value` in the WASM module by ensuring Go slices are converted to `[]any` before being passed to JavaScript. - Makes the JavaScript loader (`loader.js`) isomorphic, allowing it to run in both Node.js and browser environments. - Updates the WASM `nearest` function and the corresponding JS loader to handle the "not found" case more gracefully. - Corrects the smoke test (`smoke.mjs`) to align with the API changes and use the correct build process. --- kdtree_failure_test.go | 90 +++++++++++++++++++++++++++ npm/poindexter-wasm/PROJECT_README.md | 21 ++++++- npm/poindexter-wasm/loader.js | 60 ++++++++++++++---- npm/poindexter-wasm/smoke.mjs | 9 ++- wasm/main.go | 33 +++++++--- 5 files changed, 186 insertions(+), 27 deletions(-) create mode 100644 kdtree_failure_test.go diff --git a/kdtree_failure_test.go b/kdtree_failure_test.go new file mode 100644 index 0000000..27f6a06 --- /dev/null +++ b/kdtree_failure_test.go @@ -0,0 +1,90 @@ +package poindexter + +import ( + "testing" +) + +func TestNewKDTree_Empty(t *testing.T) { + _, err := NewKDTree[any](nil) + if err != ErrEmptyPoints { + t.Errorf("expected ErrEmptyPoints, got %v", err) + } +} + +func TestWasmSmokeEquivalent(t *testing.T) { + tree, err := NewKDTreeFromDim[string](2) + if err != nil { + t.Fatalf("NewKDTreeFromDim failed: %v", err) + } + tree.Insert(KDPoint[string]{ID: "a", Coords: []float64{0, 0}, Value: "A"}) + tree.Insert(KDPoint[string]{ID: "b", Coords: []float64{1, 0}, Value: "B"}) + _, _, found := tree.Nearest([]float64{0.9, 0.1}) + if !found { + t.Error("expected to find a nearest point") + } +} + +func TestNewKDTree_ZeroDim(t *testing.T) { + pts := []KDPoint[any]{{Coords: []float64{}}} + _, err := NewKDTree[any](pts) + if err != ErrZeroDim { + t.Errorf("expected ErrZeroDim, got %v", err) + } +} + +func TestNewKDTree_DimMismatch(t *testing.T) { + pts := []KDPoint[any]{ + {Coords: []float64{1, 2}}, + {Coords: []float64{3}}, + } + _, err := NewKDTree[any](pts) + if err != ErrDimMismatch { + t.Errorf("expected ErrDimMismatch, got %v", err) + } +} + +func TestNewKDTree_DuplicateID(t *testing.T) { + pts := []KDPoint[any]{ + {ID: "a", Coords: []float64{1, 2}}, + {ID: "a", Coords: []float64{3, 4}}, + } + _, err := NewKDTree[any](pts) + if err != ErrDuplicateID { + t.Errorf("expected ErrDuplicateID, got %v", err) + } +} + +func TestBuildND_InvalidFeatures(t *testing.T) { + _, err := BuildND[struct{}]([]struct{}{{}}, func(a struct{}) string { return "" }, nil, nil, nil) + if err != ErrInvalidFeatures { + t.Errorf("expected ErrInvalidFeatures, got %v", err) + } +} + +func TestBuildND_InvalidWeights(t *testing.T) { + _, err := BuildND[struct{}]([]struct{}{{}}, func(a struct{}) string { return "" }, []func(struct{}) float64{func(a struct{}) float64 { return 0 }}, nil, nil) + if err != ErrInvalidWeights { + t.Errorf("expected ErrInvalidWeights, got %v", err) + } +} + +func TestBuildND_InvalidInvert(t *testing.T) { + _, err := BuildND[struct{}]([]struct{}{{}}, func(a struct{}) string { return "" }, []func(struct{}) float64{func(a struct{}) float64 { return 0 }}, []float64{0}, nil) + if err != ErrInvalidInvert { + t.Errorf("expected ErrInvalidInvert, got %v", err) + } +} + +func TestBuildNDWithStats_DimMismatch(t *testing.T) { + _, err := BuildNDWithStats[struct{}]([]struct{}{{}}, func(a struct{}) string { return "" }, []func(struct{}) float64{func(a struct{}) float64 { return 0 }}, []float64{0}, []bool{false}, NormStats{}) + if err != ErrStatsDimMismatch { + t.Errorf("expected ErrStatsDimMismatch, got %v", err) + } +} + +func TestGonumStub_BuildBackend(t *testing.T) { + _, err := buildGonumBackend[any](nil, nil) + if err != ErrEmptyPoints { + t.Errorf("expected ErrEmptyPoints, got %v", err) + } +} diff --git a/npm/poindexter-wasm/PROJECT_README.md b/npm/poindexter-wasm/PROJECT_README.md index 634752c..396a316 100644 --- a/npm/poindexter-wasm/PROJECT_README.md +++ b/npm/poindexter-wasm/PROJECT_README.md @@ -71,7 +71,9 @@ Explore runnable examples in the repository: - examples/kdtree_2d_ping_hop - examples/kdtree_3d_ping_hop_geo - examples/kdtree_4d_ping_hop_geo_score +- examples/dht_helpers (convenience wrappers for common DHT schemas) - examples/wasm-browser (browser demo using the ESM loader) +- examples/wasm-browser-ts (TypeScript + Vite local demo) ### KDTree performance and notes - Dual backend support: Linear (always available) and an optimized KD backend enabled when building with `-tags=gonum`. Linear is the default; with the `gonum` tag, the optimized backend becomes the default. @@ -216,4 +218,21 @@ This project is licensed under the European Union Public Licence v1.2 (EUPL-1.2) ## Contributing -Contributions are welcome! Please feel free to submit a Pull Request. \ No newline at end of file +Contributions are welcome! Please feel free to submit a Pull Request. + + +## Coverage + +- CI produces coverage summaries as artifacts on every push/PR: + - Default job: `coverage-summary.md` (from `coverage.out`) + - Gonum-tag job: `coverage-summary-gonum.md` (from `coverage-gonum.out`) +- Locally, you can generate and inspect coverage with the Makefile: + +```bash +make cover # runs tests with race + coverage and prints the total +make coverfunc # prints per-function coverage +make cover-kdtree # filters coverage to kdtree.go +make coverhtml # writes coverage.html for visual inspection +``` + +Note: CI also uploads raw coverage profiles as artifacts (`coverage.out`, `coverage-gonum.out`). diff --git a/npm/poindexter-wasm/loader.js b/npm/poindexter-wasm/loader.js index a45c990..1febe4a 100644 --- a/npm/poindexter-wasm/loader.js +++ b/npm/poindexter-wasm/loader.js @@ -6,9 +6,12 @@ // await tree.insert({ id: 'a', coords: [0,0], value: 'A' }); // const res = await tree.nearest([0.1, 0.2]); +const isNode = typeof process !== 'undefined' && process.versions && process.versions.node; + async function loadScriptOnce(src) { + // Browser-only return new Promise((resolve, reject) => { - // If already present, resolve immediately + if (typeof document === 'undefined') return reject(new Error('loadScriptOnce requires a browser environment')); if (document.querySelector(`script[src="${src}"]`)) return resolve(); const s = document.createElement('script'); s.src = src; @@ -19,10 +22,23 @@ async function loadScriptOnce(src) { } async function ensureWasmExec(url) { - if (typeof window !== 'undefined' && typeof window.Go === 'function') return; - await loadScriptOnce(url); - if (typeof window === 'undefined' || typeof window.Go !== 'function') { - throw new Error('wasm_exec.js did not define window.Go'); + if (globalThis.Go) return; + + if (isNode) { + const { fileURLToPath } = await import('url'); + const fs = await import('fs/promises'); + const vm = await import('vm'); + const wasmExecPath = fileURLToPath(url); + const wasmExecCode = await fs.readFile(wasmExecPath, 'utf8'); + vm.runInThisContext(wasmExecCode, { filename: wasmExecPath }); + } else if (typeof window !== 'undefined') { + await loadScriptOnce(url); + } else { + throw new Error('Unsupported environment: not Node.js or a browser'); + } + + if (typeof globalThis.Go !== 'function') { + throw new Error('wasm_exec.js did not define globalThis.Go'); } } @@ -44,7 +60,15 @@ class PxTree { async dim() { return call('pxTreeDim', this.treeId); } async insert(point) { return call('pxInsert', this.treeId, point); } async deleteByID(id) { return call('pxDeleteByID', this.treeId, id); } - async nearest(query) { return call('pxNearest', this.treeId, query); } + async nearest(query) { + const res = await call('pxNearest', this.treeId, query); + if (!res.found) return { point: null, dist: 0, found: false }; + return { + point: { id: res.id, coords: res.coords, value: res.value }, + dist: res.dist, + found: true, + }; + } async kNearest(query, k) { return call('pxKNearest', this.treeId, query, k); } async radius(query, r) { return call('pxRadius', this.treeId, query, r); } async exportJSON() { return call('pxExportJSON', this.treeId); } @@ -54,21 +78,31 @@ export async function init(options = {}) { const { wasmURL = new URL('./dist/poindexter.wasm', import.meta.url).toString(), wasmExecURL = new URL('./dist/wasm_exec.js', import.meta.url).toString(), - instantiateWasm // optional custom instantiator: (source, importObject) => WebAssembly.Instance + instantiateWasm, // optional custom instantiator + fetch: customFetch, } = options; await ensureWasmExec(wasmExecURL); - const go = new window.Go(); + const go = new globalThis.Go(); + + const fetchFn = customFetch || (isNode ? (async (url) => { + const { fileURLToPath } = await import('url'); + const fs = await import('fs/promises'); + const path = fileURLToPath(url); + const bytes = await fs.readFile(path); + return new Response(bytes, { 'Content-Type': 'application/wasm' }); + }) : fetch); + let result; if (instantiateWasm) { - const source = await fetch(wasmURL).then(r => r.arrayBuffer()); + const source = await fetchFn(wasmURL).then(r => r.arrayBuffer()); const inst = await instantiateWasm(source, go.importObject); result = { instance: inst }; - } else if (WebAssembly.instantiateStreaming) { - result = await WebAssembly.instantiateStreaming(fetch(wasmURL), go.importObject); + } else if (WebAssembly.instantiateStreaming && !isNode) { + result = await WebAssembly.instantiateStreaming(fetchFn(wasmURL), go.importObject); } else { - const resp = await fetch(wasmURL); + const resp = await fetchFn(wasmURL); const bytes = await resp.arrayBuffer(); result = await WebAssembly.instantiate(bytes, go.importObject); } @@ -83,7 +117,7 @@ export async function init(options = {}) { newTree: async (dim) => { const info = call('pxNewTree', dim); return new PxTree(info.treeId); - } + }, }; return api; diff --git a/npm/poindexter-wasm/smoke.mjs b/npm/poindexter-wasm/smoke.mjs index 925a246..a03e681 100644 --- a/npm/poindexter-wasm/smoke.mjs +++ b/npm/poindexter-wasm/smoke.mjs @@ -6,9 +6,8 @@ import { init } from './loader.js'; (async function () { try { const px = await init({ - // In CI, dist/ is placed at repo root via make wasm-build && make npm-pack - wasmURL: new URL('./dist/poindexter.wasm', import.meta.url).pathname, - wasmExecURL: new URL('./dist/wasm_exec.js', import.meta.url).pathname, + wasmURL: new URL('dist/poindexter.wasm', import.meta.url), + wasmExecURL: new URL('dist/wasm_exec.js', import.meta.url), }); const ver = await px.version(); if (!ver || typeof ver !== 'string') throw new Error('version not string'); @@ -17,8 +16,8 @@ import { init } from './loader.js'; await tree.insert({ id: 'a', coords: [0, 0], value: 'A' }); await tree.insert({ id: 'b', coords: [1, 0], value: 'B' }); const nn = await tree.nearest([0.9, 0.1]); - if (!nn || !nn.id) throw new Error('nearest failed'); - console.log('WASM smoke ok:', ver, 'nearest.id=', nn.id); + if (!nn || !nn.point) throw new Error('nearest failed'); + console.log('WASM smoke ok:', ver, 'nearest.id=', nn.point.id); } catch (err) { console.error('WASM smoke failed:', err); process.exit(1); diff --git a/wasm/main.go b/wasm/main.go index 4f046d8..eb6a3ca 100644 --- a/wasm/main.go +++ b/wasm/main.go @@ -151,11 +151,12 @@ func nearest(_ js.Value, args []js.Value) (any, error) { return nil, fmt.Errorf("unknown treeId %d", id) } p, d, found := t.Nearest(query) - out := map[string]any{ - "point": map[string]any{"id": p.ID, "coords": p.Coords, "value": p.Value}, - "dist": d, - "found": found, + if !found { + return map[string]any{"found": false}, nil } + out := kdPointToJS(p) + out["dist"] = d + out["found"] = true return out, nil } @@ -177,9 +178,13 @@ func kNearest(_ js.Value, args []js.Value) (any, error) { pts, dists := t.KNearest(query, k) jsPts := make([]any, len(pts)) for i, p := range pts { - jsPts[i] = map[string]any{"id": p.ID, "coords": p.Coords, "value": p.Value} + jsPts[i] = kdPointToJS(p) } - return map[string]any{"points": jsPts, "dists": dists}, nil + jsDists := make([]any, len(dists)) + for i, d := range dists { + jsDists[i] = d + } + return map[string]any{"points": jsPts, "dists": jsDists}, nil } func radius(_ js.Value, args []js.Value) (any, error) { @@ -200,9 +205,13 @@ func radius(_ js.Value, args []js.Value) (any, error) { pts, dists := t.Radius(query, r) jsPts := make([]any, len(pts)) for i, p := range pts { - jsPts[i] = map[string]any{"id": p.ID, "coords": p.Coords, "value": p.Value} + jsPts[i] = kdPointToJS(p) } - return map[string]any{"points": jsPts, "dists": dists}, nil + jsDists := make([]any, len(dists)) + for i, d := range dists { + jsDists[i] = d + } + return map[string]any{"points": jsPts, "dists": jsDists}, nil } func exportJSON(_ js.Value, args []js.Value) (any, error) { @@ -223,6 +232,14 @@ func exportJSON(_ js.Value, args []js.Value) (any, error) { return string(b), nil } +func kdPointToJS(p pd.KDPoint[string]) map[string]any { + coords := make([]any, len(p.Coords)) + for i, v := range p.Coords { + coords[i] = v + } + return map[string]any{"id": p.ID, "coords": coords, "value": p.Value} +} + func main() { // Export core API export("pxVersion", version)