From 755afccf900c0aa25defd4e541ca83735135b040 Mon Sep 17 00:00:00 2001 From: vjrj Date: Sat, 1 Aug 2026 18:02:20 +0200 Subject: [PATCH] test(calcUnionAsync): pin the worker plumbing and the event-loop guarantee Runs the real worker against the real @turf modules, because what broke twice was never the geometry: the worker script and the turf packages sit in sibling directories of the bundle that a bare require() cannot reach (3249362), and the first fix doubled the programs/server prefix so nothing resolved (570cb49). The last test is the reason the worker exists at all: timers keep firing while a 120-circle union computes. Revert fbb746f and it fails. --- test/server/calcUnionAsync.test.js | 123 +++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 test/server/calcUnionAsync.test.js diff --git a/test/server/calcUnionAsync.test.js b/test/server/calcUnionAsync.test.js new file mode 100644 index 0000000..9950c4b --- /dev/null +++ b/test/server/calcUnionAsync.test.js @@ -0,0 +1,123 @@ +/* eslint-env mocha */ +/* eslint-disable func-names, prefer-arrow-callback */ +/* eslint-disable import/no-absolute-path */ + +import fs from 'fs'; +import path from 'path'; +import { Worker } from 'worker_threads'; +import { expect } from 'chai'; +import calcUnionAsync from '/imports/startup/server/calcUnionAsync'; + +// These run the real worker thread against the real @turf modules. The point is +// not the geometry (turf is not ours to test) but the plumbing that broke twice +// in production: the worker script and the turf packages live in sibling +// directories of the built bundle that a bare require() can never find +// (3249362), and one attempt at fixing that doubled the programs/server prefix +// so nothing resolved at all (570cb49). + +const subAt = (lat, lon, distance = 10) => ({ location: { lat, lon }, distance }); + +describe('calcUnionAsync worker plumbing', function () { + this.timeout(60000); + + it('resolves the worker script and the turf packages from the bundle layout', function () { + const worker = path.resolve(process.cwd(), 'assets/app/workers/unionWorker.js'); + expect(fs.existsSync(worker), `worker script missing at ${worker}`).to.equal(true); + + ['circle', 'union', 'truncate'].forEach((mod) => { + const turf = path.resolve(process.cwd(), 'npm/node_modules/@turf', mod); + expect(fs.existsSync(turf), `@turf/${mod} missing at ${turf}`).to.equal(true); + // The doubled-prefix regression: this path must NOT be under programs/server + // twice, which is what silently produced "Cannot find module" at runtime. + expect(turf).to.not.match(/programs\/server\/programs\/server/); + }); + }); + + it('computes a union of one circle', async function () { + const union = await calcUnionAsync([subAt(43.5, -5.9)]); + expect(union).to.be.an('object'); + expect(union.geometry.type).to.equal('Polygon'); + expect(union.geometry.coordinates[0].length).to.be.above(3); + }); + + it('returns null when there is nothing to unite', async function () { + expect(await calcUnionAsync([])).to.equal(null); + }); + + it('merges two overlapping circles into a single polygon', async function () { + const union = await calcUnionAsync([subAt(43.5, -5.9, 20), subAt(43.55, -5.9, 20)]); + expect(union.geometry.type).to.equal('Polygon'); + }); + + it('keeps two far-apart circles as a MultiPolygon', async function () { + const union = await calcUnionAsync([subAt(43.5, -5.9, 10), subAt(28.1, -15.4, 10)]); + expect(union.geometry.type).to.equal('MultiPolygon'); + expect(union.geometry.coordinates).to.have.lengthOf(2); + }); + + it('merges into a given base union instead of starting over', async function () { + const base = await calcUnionAsync([subAt(43.5, -5.9, 10)]); + const merged = await calcUnionAsync([subAt(28.1, -15.4, 10)], base); + + expect(merged.geometry.type).to.equal('MultiPolygon'); + expect(merged.geometry.coordinates).to.have.lengthOf(2); + }); + + it('truncates coordinates to 6 decimals so the stored union stays small', async function () { + const union = await calcUnionAsync([subAt(43.5, -5.9)]); + union.geometry.coordinates[0].forEach(([lon, lat]) => { + expect(String(lon).split('.')[1] || '').to.have.length.at.most(6); + expect(String(lat).split('.')[1] || '').to.have.length.at.most(6); + }); + }); + + // Regression 50ca9cc's other half: the caller must be able to tell a failure + // apart from an empty union, so a worker error has to reject, never resolve. + it('rejects when the worker throws instead of resolving to null', async function () { + let rejected = false; + try { + // A subscription with no location makes tcircle throw inside the worker. + await calcUnionAsync([{ distance: 10 }]); + } catch (e) { + rejected = true; + expect(e).to.be.an('error'); + } + expect(rejected, 'calcUnionAsync resolved instead of rejecting').to.equal(true); + }); + + it('rejects when the worker script itself cannot be loaded', async function () { + let rejected = false; + try { + await new Promise((resolve, reject) => { + const worker = new Worker(path.resolve(process.cwd(), 'assets/app/workers/doesNotExist.js')); + worker.once('message', resolve); + worker.once('error', reject); + }); + } catch (e) { + rejected = true; + } + expect(rejected).to.equal(true); + }); + + // The whole reason the worker exists (b4e5511 / fbb746f): a union big enough to + // take real CPU time must not stop the event loop from running timers, because + // that is what froze DDP and disconnected every browser. + it('leaves the event loop free while it computes', async function () { + const subs = []; + for (let i = 0; i < 120; i += 1) subs.push(subAt(40 + (i * 0.02), -4 + (i * 0.02), 25)); + + let ticks = 0; + const timer = setInterval(() => { ticks += 1; }, 10); + const started = Date.now(); + try { + await calcUnionAsync(subs); + } finally { + clearInterval(timer); + } + const elapsed = Date.now() - started; + + // If the union ran on the main thread the interval would barely fire at all. + // Half the theoretical ticks is a wide margin against a loaded CI runner. + expect(ticks, `only ${ticks} timer ticks in ${elapsed}ms`).to.be.at.least(Math.floor(elapsed / 10 / 2)); + }); +});