Merge branch 'main' into contrib/gods-006-weather-missing-observations-20261011-reviewed

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Sameh Khamis committed 2026-10-14 16:03:41 -07:00
commit 2befda5286
18 files changed
+192 -22

No files matched your search

+4
View File
@@ -26,6 +26,10 @@
- Weather records keep missing conditions unknown and refuse summaries without a measured temperature.
- Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane.
- Director timeline seeks cross the dateline along the short longitude arc.
- Treat explicitly current, complete empty vessel coverage as a successful update,
clearing obsolete contacts while retaining stale or incomplete snapshots.
+5
View File
@@ -73,6 +73,8 @@ Windows/Claude Desktop Cowork confirmation remains outstanding (#927).
## Tools and local MCP server — October 1, 2026
Tool argument string lengths follow JSON Schema Unicode code-point counts, so supplementary characters count once rather than as two UTF-16 units.
`gods-eye-view/tools` defines queries that answer questions from the app's data,
and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context
Protocol. `npm run mcp` serves Core's tools over stdio to a local MCP client,
@@ -399,6 +401,9 @@ camera moves with easing, duration and holds. Playback and seek share one
coordinate sampler; navigation/manual input revokes authored motion and pending
holds. Anchor heights and explicit endpoints require the WGS84 ellipsoid
reference. Legacy projects retain their ordinary flights, content and edits.
Seeking between ordinary camera poses uses the shortest longitude arc across the
dateline while preserving both authored endpoint longitudes, and turns heading
and roll the short way for any authored angles.
See [camera directions](DIRECTOR-CAMERA.md).
Realtime voice composes separate connection, response/tool, Radio handoff, input
+2
View File
@@ -934,6 +934,8 @@
"scripts/qa-street-level.mjs",
"src/tooling/streetLevelQa.test.mjs",
"scripts/qa-browserEvidence.mjs",
"scripts/qa-chrome.mjs",
"src/qaChrome.test.mjs",
"scripts/fixtures/street-level/mapillaryGraph.mjs",
"scripts/fixtures/street-level/kartaView.mjs",
"src/layers/streetLevel/filter.test.mjs",
+2 -2
View File
@@ -1,6 +1,7 @@
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { chromeExecutablePath } from './qa-chrome.mjs';
import {
planeSupportPoints,
poseHash,
@@ -389,8 +390,7 @@ async function main() {
const { default: puppeteer } = await import('puppeteer');
browser = await puppeteer.launch({
headless: false,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
protocolTimeout: 600000,
args: [
'--no-sandbox',
+4 -1
View File
@@ -6,6 +6,7 @@
*/
import puppeteer from 'puppeteer';
import { mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const argv = process.argv;
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4214';
@@ -65,7 +66,9 @@ try {
requestAnimationFrame(tick);
}));
await new Promise((r) => setTimeout(r, 1_000));
const path = new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url).pathname;
const path = fileURLToPath(
new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url),
);
await page.screenshot({ path });
console.log(`saved ${path}`);
}
+20
View File
@@ -0,0 +1,20 @@
/**
* The browser for gates that drive an installed Chrome. These gates were
* written against macOS Google Chrome, which Windows and Linux do not have.
*/
import { existsSync } from 'node:fs';
export const MACOS_CHROME =
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
/**
* PUPPETEER_EXECUTABLE_PATH when set, then macOS Google Chrome when it is
* installed. Otherwise undefined, so Puppeteer launches the browser it manages.
*/
export function chromeExecutablePath({
env = process.env,
exists = existsSync,
} = {}) {
if (env.PUPPETEER_EXECUTABLE_PATH) return env.PUPPETEER_EXECUTABLE_PATH;
return exists(MACOS_CHROME) ? MACOS_CHROME : undefined;
}
+2 -2
View File
@@ -10,6 +10,7 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const args = process.argv.slice(2);
@@ -35,8 +36,7 @@ const check = (name, passed, detail = '') => {
const browser = await puppeteer.launch({
headless: false,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
+2 -2
View File
@@ -3,6 +3,7 @@
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/draw';
@@ -22,8 +23,7 @@ const browser = await puppeteer.launch({
headless: !HEADFUL,
...(HEADFUL
? {
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
}
: {}),
args: [
+2 -2
View File
@@ -20,6 +20,7 @@
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/recent-imagery';
@@ -54,8 +55,7 @@ const browser = await puppeteer.launch({
headless: !HEADFUL,
...(HEADFUL
? {
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
}
: {}),
args: [
+2 -2
View File
@@ -1,11 +1,11 @@
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
/** Prove parked and moving headings in the real scene, without live feeds. */
export async function runTransitHeadingRegression(base, check) {
const browser = await puppeteer.launch({
headless: true,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
args: ['--no-sandbox', '--disable-background-timer-throttling'],
});
try {
+2 -2
View File
@@ -1,5 +1,6 @@
/** Camera-jump regression against an existing server; never starts a server. */
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
import { mkdir, writeFile } from 'node:fs/promises';
import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js';
@@ -8,8 +9,7 @@ const shots = process.env.QA_SHOTS || 'qa-shots/transit-recovery';
await mkdir(shots, { recursive: true });
const browser = await puppeteer.launch({
headless: false,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
args: [
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
+2 -2
View File
@@ -36,6 +36,7 @@ import {
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
import { createTransitHistory } from '../server/providers/transitHistory.js';
import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js';
import { transitModeTier } from '../src/data/transitPresetStyle.js';
@@ -262,8 +263,7 @@ await mkdir(SHOTS, { recursive: true });
const browser = await puppeteer.launch({
headless: false,
protocolTimeout: 150000,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
+6 -1
View File
@@ -28,6 +28,11 @@ export function resolveCameraMove(scene, shot) {
};
}
/** Wrap an interpolated camera longitude to WGS84 without changing authored endpoints. */
export function normalizeCameraLongitude(longitude) {
return ((((longitude + 180) % 360) + 360) % 360) - 180;
}
/** Sample the same authored curve for playback and seeking, taking the short longitude/angle arc. */
export function sampleCameraMove(move, progress) {
const t = Math.max(0, Math.min(1, Number(progress) || 0));
@@ -45,7 +50,7 @@ export function sampleCameraMove(move, progress) {
const { from, to } = move;
return {
lat: lerp(from.lat, to.lat),
lon: ((((angle(from.lon, to.lon) + 180) % 360) + 360) % 360) - 180,
lon: normalizeCameraLongitude(angle(from.lon, to.lon)),
alt: lerp(from.alt, to.alt),
heading: angle(from.heading, to.heading),
pitch: lerp(from.pitch, to.pitch),
@@ -0,0 +1,53 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { cameraAtProgress } from './timeline.js';
const camera = (lon) => ({
lat: 10,
lon,
alt: 1000,
heading: 0,
pitch: -40,
roll: 0,
});
test('director seek follows the short dateline arc in both directions', () => {
for (const [a, b] of [
[179, -179],
[-179, 179],
]) {
assert.equal(
Math.abs(cameraAtProgress(camera(a), camera(b), 0.5).lon),
180,
);
assert.equal(cameraAtProgress(camera(a), camera(b), 0).lon, a);
assert.equal(cameraAtProgress(camera(a), camera(b), 1).lon, b);
assert.ok(Math.abs(cameraAtProgress(camera(a), camera(b), 0.25).lon) > 179);
}
assert.equal(cameraAtProgress(camera(20), camera(24), 0.5).lon, 22);
});
test('director seek keeps exact ±180 endpoints and picks the short arc near the antipode', () => {
for (const [a, b] of [
[180, -180],
[-180, 180],
]) {
assert.equal(cameraAtProgress(camera(a), camera(b), 0).lon, a);
assert.equal(cameraAtProgress(camera(a), camera(b), 1).lon, b);
assert.equal(
Math.abs(cameraAtProgress(camera(a), camera(b), 0.5).lon),
180,
);
}
// 10°E to 169.9°W is 179.9° westward through Greenwich, not 180.1° east.
const mid = cameraAtProgress(camera(10), camera(-169.9), 0.5).lon;
assert.ok(mid > -81 && mid < -79, `short arc midpoint, got ${mid}`);
});
test('director seek turns heading and roll the short way for any authored angles', () => {
const pose = (heading, roll) => ({ ...camera(0), heading, roll });
const mid = cameraAtProgress(pose(350, 350), pose(-350, -350), 0.5);
// 350° and -350° are 20° apart through 0°/360°, not 340° through 180°.
for (const value of [mid.heading, mid.roll]) {
const wrapped = ((value % 360) + 360) % 360;
assert.ok(wrapped < 1e-9 || 360 - wrapped < 1e-9, `got ${value}`);
}
});
+10 -2
View File
@@ -2,6 +2,7 @@ import {
resolveCameraPose,
resolveCameraMove,
sampleCameraMove,
normalizeCameraLongitude,
} from './camera.js';
const DEFAULT_SHOT_DURATION_SEC = 4;
@@ -40,12 +41,19 @@ export function cameraAtProgress(fromCamera, toCamera, progress) {
const lerp = (from, to) => Number(from) + (Number(to) - Number(from)) * eased;
const lerpAngle = (from, to) => {
const start = Number(from) || 0;
const delta = ((Number(to) - start + 540) % 360) - 180;
// Double modulo keeps the delta in [-180, 180) for any authored angles,
// as sampleCameraMove does; a single % goes negative below -540.
const delta = ((((Number(to) - start + 540) % 360) + 360) % 360) - 180;
return start + delta * eased;
};
return {
lat: lerp(source.lat, target.lat),
lon: lerp(source.lon, target.lon),
lon:
t === 0
? Number(source.lon)
: t === 1
? Number(target.lon)
: normalizeCameraLongitude(lerpAngle(source.lon, target.lon)),
alt: lerp(source.alt, target.alt),
heading: lerpAngle(source.heading, target.heading),
pitch: lerp(source.pitch, target.pitch),
+27
View File
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { MACOS_CHROME, chromeExecutablePath } from '../scripts/qa-chrome.mjs';
test('gates keep macOS Chrome where it is installed', () => {
assert.equal(
chromeExecutablePath({ env: {}, exists: (p) => p === MACOS_CHROME }),
MACOS_CHROME,
);
});
test('gates fall back to Puppeteer’s browser without macOS Chrome', () => {
assert.equal(
chromeExecutablePath({ env: {}, exists: () => false }),
undefined,
);
});
test('an explicit executable path wins', () => {
assert.equal(
chromeExecutablePath({
env: { PUPPETEER_EXECUTABLE_PATH: 'C:\\Chrome\\chrome.exe' },
exists: () => true,
}),
'C:\\Chrome\\chrome.exe',
);
});
+14 -4
View File
@@ -106,10 +106,20 @@ export function validateValue(schema, value, path = 'arguments') {
fail('must be a string');
break;
}
if (schema.minLength != null && value.length < schema.minLength)
fail(`must be at least ${schema.minLength} characters`);
if (schema.maxLength != null && value.length > schema.maxLength)
fail(`must be at most ${schema.maxLength} characters`);
if (schema.minLength != null || schema.maxLength != null) {
// Code points, as JSON Schema counts. Counting stops once both bounds
// are decided, so a huge argument costs no more than its limit.
const limit = Math.max(
schema.minLength ?? 0,
schema.maxLength != null ? schema.maxLength + 1 : 0,
);
let length = 0;
for (const _ of value) if (++length >= limit) break;
if (schema.minLength != null && length < schema.minLength)
fail(`must be at least ${schema.minLength} characters`);
if (schema.maxLength != null && length > schema.maxLength)
fail(`must be at most ${schema.maxLength} characters`);
}
if (
schema.pattern !== undefined &&
!new RegExp(schema.pattern, 'u').test(value)
+33
View File
@@ -0,0 +1,33 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { validateValue } from './schema.js';
test('tool string schemas count Unicode characters rather than UTF-16 units', () => {
const value = '𠮷野家😀';
assert.deepEqual(validateValue({ type: 'string', maxLength: 4 }, value), []);
assert.equal(
validateValue({ type: 'string', minLength: 5 }, value).length,
1,
);
assert.equal(
validateValue({ type: 'string', maxLength: 3 }, value).length,
1,
);
assert.deepEqual(
validateValue({ type: 'string', minLength: 4, maxLength: 4 }, 'abcd'),
[],
);
assert.equal(validateValue({ type: 'string', minLength: 2 }, '𠮷').length, 1);
});
test('tool string bounds hold at their edges and on huge arguments', () => {
const string = (bounds) => ({ type: 'string', ...bounds });
assert.deepEqual(validateValue(string({ maxLength: 3 }), '😀😀😀'), []);
assert.equal(validateValue(string({ maxLength: 3 }), '😀😀😀😀').length, 1);
assert.deepEqual(validateValue(string({ minLength: 3 }), '😀😀😀'), []);
assert.deepEqual(validateValue(string({}), 'x'.repeat(1e6)), []);
// Counting all of it takes roughly 0.7 s; stopping at the limit takes none.
const huge = 'x'.repeat(1e8);
const started = performance.now();
assert.equal(validateValue(string({ maxLength: 60 }), huge).length, 1);
assert.ok(performance.now() - started < 200, 'stops counting past the limit');
});