diff --git a/build/registerVue.js b/build/registerVue.js index eb8dc4736..6db7d017c 100644 --- a/build/registerVue.js +++ b/build/registerVue.js @@ -1,5 +1,8 @@ -import { register } from 'node:module'; +import { registerHooks } from 'node:module'; +import { load } from './vueLoader.js'; // Lets Node import single-file components: `node --import=`. -// Hosts reach it as `/build/register-vue`. -register('./vueLoader.js', import.meta.url); +// Hosts reach it as `/build/register-vue`. The hooks run on the +// loading thread: off-thread hooks (module.register) would add a round trip +// to every module a test imports, not only the components. +registerHooks({ load }); diff --git a/build/vueLoader.js b/build/vueLoader.js index a549fca47..13026dc04 100644 --- a/build/vueLoader.js +++ b/build/vueLoader.js @@ -1,14 +1,32 @@ -import { readFile } from 'node:fs/promises'; +import { createHash, randomBytes } from 'node:crypto'; +import { + lstatSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { compileScript, compileTemplate, parse } from 'vue/compiler-sfc'; import { VUE_TEMPLATE_OPTIONS } from './vueOptions.js'; +const require = createRequire(import.meta.url); + +// The compiler loads on the first component that is not cached, so a test +// process that imports no views, or only cached ones, never pays for it. +let compiler = null; +const sfcCompiler = () => (compiler ??= require('vue/compiler-sfc')); + /** * Compile a single-file component to an ES module for Node, so unit tests (the * application's and a host's) can import components without a bundler. * Styles are dropped: tests read the global stylesheet separately. */ export function compileVueModule(source, filename) { + const { compileScript, compileTemplate, parse } = sfcCompiler(); // The template is parsed with the SFC; parse it as the browser build does. const { descriptor, errors } = parse(source, { filename, @@ -40,14 +58,113 @@ export function compileVueModule(source, filename) { return `${script}\n${template}\nif (typeof render === 'function') __sfc__.render = render;\nexport default __sfc__;\n`; } -/** Node module hook: load `.vue` files as compiled modules. */ -export async function load(url, context, nextLoad) { +// The loader's own code is part of every key, so a change to how components +// compile never reuses output from before it. +let loaderFingerprint = null; +const fingerprint = () => + (loaderFingerprint ??= createHash('sha256') + .update(readFileSync(fileURLToPath(import.meta.url))) + .update(readFileSync(new URL('./vueOptions.js', import.meta.url))) + .update(require('vue/package.json').version) + .digest('hex')); + +/** + * Where compiled components are kept between test processes, never a + * package's own tree (a host's checks refuse files inside Core): the system + * temp directory, or on Windows the per-user local app data directory, since + * a temp directory there may be shared and its ownership is not checked. + * GEV_VUE_CACHE=0 compiles every time. + */ +export function vueCacheDirectory( + env = process.env, + platform = process.platform, +) { + if (env.GEV_VUE_CACHE === '0') return null; + if (platform === 'win32') { + return env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'gev-vue-sfc') : null; + } + return path.join(tmpdir(), `gev-vue-sfc-${process.getuid()}`); +} + +/** The cache entry for one component: its path, its text and the loader. */ +export function vueCacheKey(source, filename) { + return createHash('sha256') + .update(JSON.stringify([fingerprint(), filename])) + .update('\0') + .update(source) + .digest('hex'); +} + +/** + * Compile a component, reusing a previous process's result for the same text. + * A cache that cannot be read or written only costs the compile. + */ +export function cachedVueModule( + source, + filename, + directory = vueCacheDirectory(), +) { + if (!directory || !ownCacheDirectory(directory)) { + return compileVueModule(source, filename); + } + const entry = path.join(directory, `${vueCacheKey(source, filename)}.mjs`); + try { + return readFileSync(entry, 'utf8'); + } catch { + // Not compiled yet. + } + const compiled = compileVueModule(source, filename); + // Concurrent test processes and threads may compile the same component; + // each writes its own new file and the rename makes the entry appear whole. + const partial = `${entry}.${randomBytes(6).toString('hex')}.tmp`; + try { + writeFileSync(partial, compiled, { flag: 'wx', mode: 0o600 }); + renameSync(partial, entry); + } catch { + // An unwritable directory, or a rename another process holds open, + // leaves the cache without this entry. + try { + rmSync(partial, { force: true }); + } catch { + // A partial file that cannot be removed is only clutter. + } + } + return compiled; +} + +/** + * Cached modules run as code, so the directory must be this user's own: a + * real directory (not a link), owned by the current user and writable by no + * one else. In a shared temp directory another user could otherwise create it + * first and plant modules. On Windows it sits in the user's own profile. + */ +const checkedDirectories = new Map(); +export function ownCacheDirectory(directory) { + if (!checkedDirectories.has(directory)) { + checkedDirectories.set(directory, isOwnDirectory(directory)); + } + return checkedDirectories.get(directory); +} + +function isOwnDirectory(directory) { + try { + mkdirSync(directory, { recursive: true, mode: 0o700 }); + const stats = lstatSync(directory); + if (!stats.isDirectory()) return false; + if (process.platform === 'win32') return true; + return stats.uid === process.getuid() && (stats.mode & 0o022) === 0; + } catch { + return false; + } +} + +/** Synchronous module hook (module.registerHooks): `.vue` loads compiled. */ +export function load(url, context, nextLoad) { if (!url.endsWith('.vue')) return nextLoad(url, context); const filename = fileURLToPath(url); - const source = await readFile(filename, 'utf8'); return { format: 'module', - source: compileVueModule(source, filename), + source: cachedVueModule(readFileSync(filename, 'utf8'), filename), shortCircuit: true, }; } diff --git a/changelog.d/1432-vessel-course-north.md b/changelog.d/1432-vessel-course-north.md new file mode 100644 index 000000000..45d9e5845 --- /dev/null +++ b/changelog.d/1432-vessel-course-north.md @@ -0,0 +1,3 @@ +- Show a vessel course of 359.5° or more as 0°. Vessel cards and the AIS HUD + rounded the course without wrapping, so a ship with no reported heading on + a 359.7° course read "360°" and "HDG: 360DEG" (Aditya, @Rohilalala, #1432). diff --git a/changelog.d/1441-cctv-heading-north.md b/changelog.d/1441-cctv-heading-north.md new file mode 100644 index 000000000..d65e5c2d8 --- /dev/null +++ b/changelog.d/1441-cctv-heading-north.md @@ -0,0 +1,3 @@ +- Show a CCTV camera facing 359.5° or more as HDG 0°. The HUD heading token + and the camera panel rounded the bearing without wrapping, so a camera + facing 359.6° read "HDG 360°" (Aditya, @Rohilalala, #1441). diff --git a/changelog.d/1451-alpr-direction-north.md b/changelog.d/1451-alpr-direction-north.md new file mode 100644 index 000000000..74f04b7ab --- /dev/null +++ b/changelog.d/1451-alpr-direction-north.md @@ -0,0 +1,2 @@ +- Show an ALPR camera's mapped bearing of 359.5° or more as DIRECTION 0° + rather than DIRECTION 360° (Aditya, @Rohilalala, #1451). diff --git a/changelog.d/1500-vue-loader-cache.md b/changelog.d/1500-vue-loader-cache.md new file mode 100644 index 000000000..da2ff7c13 --- /dev/null +++ b/changelog.d/1500-vue-loader-cache.md @@ -0,0 +1,4 @@ +- `build/register-vue` loads components on the test thread and keeps compiled + components between test processes in a per-user cache directory, so Node + tests that import views run about twice as fast. `GEV_VUE_CACHE=0` compiles + every time (samehkhamis, #1500). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ecf0d5356..5a98c0702 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -557,7 +557,9 @@ those reports arrive as 102.3 knots and 360 degrees. Both are stored as unknown, and vessel cards, the HUD and analyst queries show a missing measurement rather than a reading. Heading keeps its existing separate check for its own sentinel. Genuine readings are unchanged, including a stopped vessel's zero and the -highest speed and course the fields can encode. +highest speed and course the fields can encode. Vessel cards and the HUD wrap +the direction after rounding to whole degrees, so a course of 359.5° or more +reads `0°` (`0DEG` in the HUD) rather than `360°`. For an already tracked vessel, AISStream static messages update name, type, destination and IMO without changing its position, motion, fix timestamps, @@ -958,7 +960,9 @@ change. Once the camera is still and tiles have settled, each overlay anchor is replaced once by the rendered surface under it (two depth samples per paint, accepted only within -30/+40 m of the floor cell), so badges do not float or sink on slopes; wedge corners follow their own floor cells instead of staying -level with the apex. Unknown bearings never acquire a wedge. The row reads +level with the apex. Unknown bearings never acquire a wedge. The selected +card's `DIRECTION` wraps after rounding to whole degrees, so a mapped bearing +of 359.5° or more reads `0°` rather than `360°`. The row reads `N nearby · M on screen`; when every loaded camera is outside the view it adds "None on screen — nearby cameras are outside the view" instead of looking unloaded. `scripts/qa-alpr-journey.mjs` drives an Austin journey with ALPR off, @@ -4926,7 +4930,9 @@ silently demoting every later lookup for the session. wireframe dashed; colours, widths and active/idle emphasis are unchanged. A manual calibration (`calSource: 'manual'`) or curated pose (`poseSource: 'curated'`) is never presented as estimated, matching the CAL badge. Public camera state carries `headingConfidence` and - `headingEstimated` (`src/layers/cctv/headingConfidence.js`, #639). + `headingEstimated` (`src/layers/cctv/headingConfidence.js`, #639). The HUD token and the + camera panel's meta line wrap the bearing after rounding to whole degrees, so a camera + facing 359.5° or more reads `HDG 0°` rather than `HDG 360°`. - **CCTV identified hardware:** a camera whose source or seed names a hardware `model` that resolves in `src/layers/cctv/modelSpecs.js` (vendored CC0 records from the CCTV Camera Database) gets the model's specs appended to the HUD meta line. Only fixed single-lens diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 53bb0b66b..613e55dc5 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -68,6 +68,7 @@ "server/standalone/vite.config.js", "vite.config.js", "src/tooling/viteBuild.test.mjs", + "src/tooling/vueLoader.test.mjs", "src/tooling/allowedHosts.test.mjs", "server/providers/common/http.js", "server/providers/common/query.js", diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index 050bb0323..8e12bff94 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1321,6 +1321,27 @@ test('buildVesselCard: heading falls back to course; missing parts are omitted', assert.deepEqual(bare.details, []); }); +test('vessel cards read a course that rounds to north as 0°, not 360°', () => { + const card = (course) => buildVesselCard(makeRecord({ heading: null, course })).details[0]; + assert.equal(card(359.4), 'CONTAINER SHIP · 14.5KT · 359°'); + assert.equal(card(359.7), 'CONTAINER SHIP · 14.5KT · 0°'); + assert.equal( + buildSelectedVesselCard(makeRecord({ heading: null, course: 359.7 })).details[0], + 'CONTAINER SHIP · 14.5KT · 0°', + ); +}); + +test('the vessel HUD reads a course that rounds to north as HDG 0DEG', () => { + const harness = installWireHarness(undefined, { selectedRecord: null }); + try { + Object.assign(harness.record, { heading: null, course: 359.7 }); + assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true); + assert.match(harness.hud.readout.text, /HDG: 0DEG/); + } finally { + harness.cleanup(); + } +}); + test('buildVesselCard: unnamed vessels title as MMSI; long names truncate', () => { const unnamed = buildVesselCard(makeRecord({ name: 'VESSEL' })); assert.equal(unnamed.title, 'MMSI 353136000'); diff --git a/src/layers/alpr/overlay.test.mjs b/src/layers/alpr/overlay.test.mjs index 1bb9985af..c9aa8e5f5 100644 --- a/src/layers/alpr/overlay.test.mjs +++ b/src/layers/alpr/overlay.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { createAlprOverlay } from './overlay.js'; +import { normalizeAlprNode } from './records.js'; import { directionWedgePositions, alprLabelDetails, @@ -218,6 +219,23 @@ test('adapter labels never claim a custom source is public OSM data', () => { ); }); +test('a mapped bearing that rounds up to a whole turn reads 0°, not 360°', () => { + const direction = (tag) => + alprLabelDetails( + normalizeAlprNode({ + type: 'node', + id: 1, + lat: 30, + lon: -97, + tags: { 'surveillance:type': 'ALPR', 'camera:direction': tag }, + }), + { label: 'Custom directory' }, + ).find((line) => line.startsWith('DIRECTION')); + assert.equal(direction('359.6'), 'DIRECTION 0°'); + assert.equal(direction('359.4'), 'DIRECTION 359°'); + assert.equal(direction('0.4'), 'DIRECTION 0°'); +}); + test('overlay glyphs and native badges shrink alike with distance', () => { assert.equal(markerScale(100), 1.15); assert.equal(markerScale(40_000), 0.45); diff --git a/src/layers/alpr/visuals.js b/src/layers/alpr/visuals.js index 3a3d3fc5f..2a9202d92 100644 --- a/src/layers/alpr/visuals.js +++ b/src/layers/alpr/visuals.js @@ -49,8 +49,9 @@ export function alprLabelDetails(record, source) { const details = [ sourceName === 'OpenStreetMap' ? 'OSM MAPPED' : `Source: ${sourceName}`, ]; + // Wrap after rounding: 359.6° rounds to 360°, which is north. if (Number.isFinite(record.directionDeg)) - details.push(`DIRECTION ${Math.round(record.directionDeg)}°`); + details.push(`DIRECTION ${Math.round(record.directionDeg) % 360}°`); const equipment = [ ...new Set( [record.manufacturer, record.operator, record.cameraType] diff --git a/src/layers/cctv/headingConfidence.js b/src/layers/cctv/headingConfidence.js index 26a877558..fad0867de 100644 --- a/src/layers/cctv/headingConfidence.js +++ b/src/layers/cctv/headingConfidence.js @@ -44,7 +44,8 @@ export function isHeadingEstimated(camera) { * @returns {string} */ export function headingHudToken(camera) { - const hdg = Math.round(Number(camera?.headingDeg) || 0); + // 359.5° and above rounds to north; modulo keeps it from printing 360°. + const hdg = Math.round(Number(camera?.headingDeg) || 0) % 360; return isHeadingEstimated(camera) ? t('HDG {value}° (ESTIMATED)', { value: hdg }) : t('HDG {value}°', { value: hdg }); diff --git a/src/layers/cctv/headingConfidence.test.mjs b/src/layers/cctv/headingConfidence.test.mjs index 513bf0d1b..3cf673861 100644 --- a/src/layers/cctv/headingConfidence.test.mjs +++ b/src/layers/cctv/headingConfidence.test.mjs @@ -44,6 +44,15 @@ test('HUD token tags a synthetic bearing and rounds the degrees', () => { ); }); +test('HUD token reads a bearing that rounds to north as 0°, not 360°', () => { + assert.equal(headingHudToken({ headingDeg: 359.4 }), 'HDG 359°'); + assert.equal(headingHudToken({ headingDeg: 359.6 }), 'HDG 0°'); + assert.equal( + headingHudToken({ headingDeg: 359.5, headingConfidence: 'low' }), + 'HDG 0° (ESTIMATED)', + ); +}); + test('HUD token stays untagged for surveyed and calibrated bearings', () => { assert.equal( headingHudToken({ headingDeg: 67.5, headingConfidence: 'high' }), diff --git a/src/layers/vessels/cards.js b/src/layers/vessels/cards.js index aa6f06717..2cab53ed1 100644 --- a/src/layers/vessels/cards.js +++ b/src/layers/vessels/cards.js @@ -55,7 +55,10 @@ export function createCards({ if (record.speed !== null && record.speed !== undefined) parts.push(formatSpeed(record.speed)); const direction = record.heading ?? record.course; - if (Number.isFinite(direction)) parts.push(`${Math.round(direction)}°`); + // A course of 359.5° and above rounds to north; modulo keeps it from + // printing 360°. The selected card and the HUD wrap the same way. + if (Number.isFinite(direction)) + parts.push(`${Math.round(direction) % 360}°`); return { id: vesselOverlayEntryId(record), actionable: Boolean(record?.mmsi), @@ -86,7 +89,7 @@ export function createCards({ [ vesselTypeShort(record) || t('VESSEL'), formatSpeed(record.speed), - Number.isFinite(direction) ? `${Math.round(direction)}°` : '--°', + Number.isFinite(direction) ? `${Math.round(direction) % 360}°` : '--°', ].join(' · '), ]; const destination = String(record.destination || '').trim(); @@ -176,7 +179,7 @@ export function createCards({ function formatHeading(heading) { return t('{heading}DEG', { - heading: Number.isFinite(heading) ? Math.round(heading) : '--', + heading: Number.isFinite(heading) ? Math.round(heading) % 360 : '--', }); } diff --git a/src/tooling/vueLoader.test.mjs b/src/tooling/vueLoader.test.mjs new file mode 100644 index 000000000..772299c0b --- /dev/null +++ b/src/tooling/vueLoader.test.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + cachedVueModule, + compileVueModule, + ownCacheDirectory, + vueCacheDirectory, + vueCacheKey, +} from '../../build/vueLoader.js'; + +const SOURCE = + '\n\n'; +const FILE = '/virtual/Greeting.vue'; + +test('a component compiles once and later processes read the cached module', (t) => { + const directory = mkdtempSync(path.join(tmpdir(), 'gev-vue-cache-test-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const compiled = cachedVueModule(SOURCE, FILE, directory); + assert.equal(compiled, compileVueModule(SOURCE, FILE)); + const entries = readdirSync(directory); + assert.deepEqual(entries, [`${vueCacheKey(SOURCE, FILE)}.mjs`]); + // A cached entry is returned as stored, without compiling again. + writeFileSync(path.join(directory, entries[0]), 'export default "cached";'); + assert.equal( + cachedVueModule(SOURCE, FILE, directory), + 'export default "cached";', + ); +}); + +test('an edited component or another path is a new cache entry', () => { + const key = vueCacheKey(SOURCE, FILE); + assert.notEqual(vueCacheKey(`${SOURCE}\n`, FILE), key); + assert.notEqual(vueCacheKey(SOURCE, '/virtual/Other.vue'), key); + assert.equal(vueCacheKey(SOURCE, FILE), key); +}); + +test('GEV_VUE_CACHE=0 compiles every time; otherwise the cache sits in a per-user directory', () => { + assert.equal(vueCacheDirectory({ GEV_VUE_CACHE: '0' }), null); + if (process.platform !== 'win32') { + const directory = vueCacheDirectory({}); + assert.equal(path.dirname(directory), tmpdir()); + assert.match(path.basename(directory), /^gev-vue-sfc-/); + } + // Windows temp directories may be shared, so the cache lives in the + // user's profile, or not at all. + assert.equal( + vueCacheDirectory( + { LOCALAPPDATA: 'C:\\Users\\a\\AppData\\Local' }, + 'win32', + ), + path.join('C:\\Users\\a\\AppData\\Local', 'gev-vue-sfc'), + ); + assert.equal(vueCacheDirectory({}, 'win32'), null); + assert.equal( + cachedVueModule(SOURCE, FILE, null), + compileVueModule(SOURCE, FILE), + ); +}); + +test('a cache directory that cannot be written still returns the compiled module', (t) => { + const directory = mkdtempSync(path.join(tmpdir(), 'gev-vue-cache-test-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + // A file where the cache directory should be makes every write fail. + const blocked = path.join(directory, 'blocked'); + writeFileSync(blocked, ''); + assert.equal( + cachedVueModule(SOURCE, FILE, blocked), + compileVueModule(SOURCE, FILE), + ); +}); + +test( + 'a cache directory others can write to, or a link to one, is never read', + { + skip: process.platform === 'win32', + }, + (t) => { + const root = mkdtempSync(path.join(tmpdir(), 'gev-vue-cache-test-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const shared = path.join(root, 'shared'); + mkdirSync(shared); + chmodSync(shared, 0o777); + // A module planted where the cache would look must not run. + writeFileSync( + path.join(shared, `${vueCacheKey(SOURCE, FILE)}.mjs`), + 'export default "planted";', + ); + assert.equal(ownCacheDirectory(shared), false); + assert.equal( + cachedVueModule(SOURCE, FILE, shared), + compileVueModule(SOURCE, FILE), + ); + const own = path.join(root, 'own'); + mkdirSync(own, { mode: 0o700 }); + const link = path.join(root, 'link'); + symlinkSync(own, link); + assert.equal(ownCacheDirectory(link), false); + assert.equal(ownCacheDirectory(own), true); + }, +); diff --git a/src/ui/cctvPanel.test.mjs b/src/ui/cctvPanel.test.mjs index 1675d6119..a3dbef786 100644 --- a/src/ui/cctvPanel.test.mjs +++ b/src/ui/cctvPanel.test.mjs @@ -490,3 +490,26 @@ test('the scene summary types in, then holds the whole text', async (t) => { await nextTick(); assert.equal(byId('cctv-summary').textContent, text); }); + +test('the panel meta line reads a bearing that rounds to north as HDG 0°', () => { + const meta = (headingDeg) => { + const camera = { + id: 'a', + city: 'Austin', + headingDeg, + fovDeg: 74, + rangeM: 700, + }; + return presentCctvPanel( + { + enabled: true, + activeCameraId: camera.id, + activeCamera: camera, + cameras: [camera], + }, + true, + ).meta; + }; + assert.match(meta(359.4), /· HDG 359° ·/); + assert.match(meta(359.6), /· HDG 0° ·/); +}); diff --git a/src/ui/cctvPresentation.js b/src/ui/cctvPresentation.js index c5daf7163..cd298e108 100644 --- a/src/ui/cctvPresentation.js +++ b/src/ui/cctvPresentation.js @@ -107,7 +107,7 @@ function metaText(state, enabled, activeCamera, catalogCount) { '{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}', { city: activeCamera.city, - heading: `${Math.round(activeCamera.headingDeg)}°`, + heading: `${Math.round(activeCamera.headingDeg) % 360}°`, fov: `${Math.round(activeCamera.fovDeg)}°`, range: Math.round(activeCamera.rangeM), projection: projLabel,