mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
2635 lines
96 KiB
JavaScript
2635 lines
96 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* qa-voice-routing.mjs — voice-surface QA harness (overnight 2026-07-27).
|
|
*
|
|
* Two independent layers:
|
|
*
|
|
* LAYER 1 — ROUTING (costs model turns, budget-capped):
|
|
* Sends TEXT turns through a real OpenAI Realtime session minted by the
|
|
* app's own /api/realtime/token endpoint — so the instructions + tool defs
|
|
* under test are the PRODUCTION config, not a copy. Asserts which tool the
|
|
* model calls for each phrase (plus a few critical-arg spot checks).
|
|
* Batches phrases per session (fresh-ish context, amortized mints), hard
|
|
* budget on model turns, JSONL evidence log per run.
|
|
*
|
|
* LAYER 2 — BEHAVIOR (free, deterministic, no model):
|
|
* Drives window.__gevVoiceCommands.runner(toolName, args) in a headless
|
|
* page against the dev server and asserts world state via __godsEyeView /
|
|
* __gevAnnotations (camera altitude bands, annotation counts, route
|
|
* geometry, framing modes). This is the instrument that later gates new
|
|
* tools (analyst queries, camera verbs) without anyone speaking.
|
|
*
|
|
* Usage:
|
|
* node scripts/qa-voice-routing.mjs # both layers, :4415
|
|
* node scripts/qa-voice-routing.mjs --layer routing --budget 120
|
|
* node scripts/qa-voice-routing.mjs --layer behavior --url http://localhost:4173
|
|
* node scripts/qa-voice-routing.mjs --layer behavior --behavior outlines
|
|
* node scripts/qa-voice-routing.mjs --layer behavior --behavior acceptance [--headful] [--shots qa-shots/outlines]
|
|
* # operator-only: may spend the configured guarded Nominatim allowance
|
|
* node scripts/qa-voice-routing.mjs --layer behavior --deixis-only --headful --shots qa-shots/point-and-ask
|
|
* node scripts/qa-voice-routing.mjs --layer deixis --budget 12 # pointer_context routing (model turns)
|
|
* node scripts/qa-voice-routing.mjs --layer deixis-live --url ... # in-app text turns (model turns)
|
|
*
|
|
* House gotchas honored: camera.cancelFlight() before every teleport; puppeteer
|
|
* suites must run sequentially with other harnesses (SwiftShader saturation);
|
|
* routing assertions pin tool NAMES (model wording varies, tool choice must not).
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import WebSocket from 'ws';
|
|
import puppeteer from 'puppeteer';
|
|
import sharp from 'sharp';
|
|
import { ROUTING_PHRASES } from './voice-bench/phrases.mjs';
|
|
import { gradeCore, expectLabel } from './voice-bench/grade.mjs';
|
|
import { countWords, findHedges } from '../src/voice/speechLint.js';
|
|
import {
|
|
buildPointerSnapshot,
|
|
pointerContextItem,
|
|
} from '../src/voice/pointerContext.js';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
const CHROME_CANDIDATES = [
|
|
process.env.PUPPETEER_EXECUTABLE_PATH,
|
|
await puppeteer.executablePath().catch(() => null),
|
|
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
].filter(Boolean);
|
|
const CHROME_EXECUTABLE = CHROME_CANDIDATES.find((candidate) => {
|
|
try {
|
|
return fs.existsSync(candidate);
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
|
|
// ── CLI ─────────────────────────────────────────────────────
|
|
function getOpt(flag, fallback) {
|
|
const i = process.argv.indexOf(flag);
|
|
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
|
|
}
|
|
const APP_URL = getOpt('--url', 'http://localhost:4415');
|
|
const LAYER = getOpt('--layer', 'all'); // routing | behavior | all | deixis | deixis-live
|
|
const TURN_BUDGET = Number(getOpt('--budget', '120'));
|
|
const PHRASES_PER_SESSION = Number(getOpt('--batch', '6'));
|
|
const ONLY = getOpt('--only', null); // substring filter on phrase text
|
|
const BEHAVIOR = getOpt('--behavior', 'all'); // all | outlines | acceptance
|
|
const DEIXIS_ONLY = process.argv.includes('--deixis-only'); // behavior: point-and-ask block only
|
|
const HEADFUL = process.argv.includes('--headful'); // real GPU, visible window
|
|
const SHOT_DIR = getOpt('--shots', null); // point-and-ask evidence screenshots
|
|
|
|
// ── Reporting ───────────────────────────────────────────────
|
|
let pass = 0,
|
|
fail = 0,
|
|
skip = 0;
|
|
const failures = [];
|
|
function report(ok, label, detail) {
|
|
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
|
console.log(` [${tag}] ${label} — ${detail}`);
|
|
if (ok) pass += 1;
|
|
else {
|
|
fail += 1;
|
|
failures.push({ label, detail });
|
|
}
|
|
}
|
|
function skipped(label, detail) {
|
|
console.log(` [\x1b[33mSKIP\x1b[0m] ${label} — ${detail}`);
|
|
skip += 1;
|
|
}
|
|
|
|
async function inspectPointerCrop(captured, screenPx, viewport) {
|
|
const crop = captured?.dataUrl;
|
|
const rect = captured?.rect;
|
|
if (!crop || !rect || !screenPx || !viewport?.w || !viewport?.h) return null;
|
|
const encoded = crop.split(',')[1];
|
|
if (!encoded) return null;
|
|
const { data, info } = await sharp(Buffer.from(encoded, 'base64'))
|
|
.removeAlpha()
|
|
.raw()
|
|
.toBuffer({ resolveWithObject: true });
|
|
const pointX = ((screenPx.x / viewport.w - rect.x) / rect.w) * info.width;
|
|
const pointY = ((screenPx.y / viewport.h - rect.y) / rect.h) * info.height;
|
|
let ringPixels = 0;
|
|
let usablePixels = 0;
|
|
for (let y = 0; y < info.height; y += 1) {
|
|
for (let x = 0; x < info.width; x += 1) {
|
|
const offset = (y * info.width + x) * info.channels;
|
|
const red = data[offset];
|
|
const green = data[offset + 1];
|
|
const blue = data[offset + 2];
|
|
const luminance = red * 0.2126 + green * 0.7152 + blue * 0.0722;
|
|
if (luminance > 5) usablePixels += 1;
|
|
const distance = Math.hypot(x - pointX, y - pointY);
|
|
if (
|
|
distance >= 12 &&
|
|
distance <= 20 &&
|
|
green > 120 &&
|
|
blue > 120 &&
|
|
green - red > 45 &&
|
|
blue - red > 45
|
|
)
|
|
ringPixels += 1;
|
|
}
|
|
}
|
|
return {
|
|
width: info.width,
|
|
height: info.height,
|
|
ringPixels,
|
|
usableRatio: usablePixels / (info.width * info.height),
|
|
};
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════
|
|
// LAYER 1 — ROUTING
|
|
// ════════════════════════════════════════════════════════════
|
|
|
|
// Phrase table + grading live in scripts/voice-bench/ (shared with the
|
|
// provider-neutral benchmark, qa-voice-bench.mjs). See phrases.mjs for the
|
|
// expect / oneOf / expectNone / args / argsByTool semantics.
|
|
const PHRASES = ROUTING_PHRASES;
|
|
|
|
/** Mint a session via the app's own endpoint. */
|
|
async function mintSession() {
|
|
const res = await fetch(`${APP_URL}/api/realtime/token`, { method: 'POST' });
|
|
if (!res.ok)
|
|
throw new Error(
|
|
`token mint failed: ${res.status} ${(await res.text()).slice(0, 120)}`,
|
|
);
|
|
const body = await res.json();
|
|
const value = body?.value || body?.client_secret?.value;
|
|
const model = body?.session?.model || 'gpt-realtime';
|
|
if (!value)
|
|
throw new Error(
|
|
`token mint returned no client secret: ${JSON.stringify(body).slice(0, 160)}`,
|
|
);
|
|
return { value, model };
|
|
}
|
|
|
|
/** One Realtime WS session that can run several text turns sequentially. */
|
|
class RoutingSession {
|
|
constructor(secret, model, evidence) {
|
|
this.secret = secret;
|
|
this.model = model;
|
|
this.evidence = evidence;
|
|
this.ws = null;
|
|
this.pending = null; // active turn collector
|
|
}
|
|
|
|
connect() {
|
|
return new Promise((resolve, reject) => {
|
|
const url = `wss://api.openai.com/v1/realtime?model=${encodeURIComponent(this.model)}`;
|
|
const ws = new WebSocket(url, {
|
|
headers: { Authorization: `Bearer ${this.secret}` },
|
|
});
|
|
const timer = setTimeout(
|
|
() => reject(new Error('ws connect timeout')),
|
|
15000,
|
|
);
|
|
ws.on('open', () => {
|
|
clearTimeout(timer);
|
|
this.ws = ws;
|
|
resolve();
|
|
});
|
|
ws.on('error', (e) => {
|
|
clearTimeout(timer);
|
|
reject(e);
|
|
});
|
|
ws.on('message', (raw) => this.onMessage(raw));
|
|
});
|
|
}
|
|
|
|
onMessage(raw) {
|
|
let msg;
|
|
try {
|
|
msg = JSON.parse(raw.toString());
|
|
} catch {
|
|
return;
|
|
}
|
|
this.evidence.write(
|
|
`${JSON.stringify({ at: new Date().toISOString(), dir: 'recv', type: msg.type, item: msg.item?.type, name: msg.item?.name ?? msg.name, error: msg.error?.message })}\n`,
|
|
);
|
|
if (!this.pending) return;
|
|
if (
|
|
msg.type === 'response.output_item.done' &&
|
|
msg.item?.type === 'message'
|
|
) {
|
|
// Speech stats only: text said before a call in the same response is a
|
|
// preamble; text after the last call is the spoken answer.
|
|
const text = (msg.item.content || [])
|
|
.map((part) => part?.text ?? part?.transcript ?? '')
|
|
.join(' ')
|
|
.trim();
|
|
if (text)
|
|
this.pending.messages.push({
|
|
text,
|
|
response: this.pending.continuations,
|
|
callsBefore: this.pending.calls.length,
|
|
});
|
|
}
|
|
if (
|
|
msg.type === 'response.output_item.done' &&
|
|
msg.item?.type === 'function_call'
|
|
) {
|
|
this.pending.callResponses.push(this.pending.continuations);
|
|
let parsed = {};
|
|
try {
|
|
parsed = JSON.parse(msg.item.arguments || '{}');
|
|
} catch {
|
|
/* keep {} */
|
|
}
|
|
this.pending.calls.push({ name: msg.item.name, args: parsed });
|
|
// Feed a neutral tool result (item.create is legal mid-response); the
|
|
// continuation response is requested ONLY after response.done — sending
|
|
// response.create while a response is active is an API error (the
|
|
// pilot's failure mode).
|
|
this.send({
|
|
type: 'conversation.item.create',
|
|
item: {
|
|
type: 'function_call_output',
|
|
call_id: msg.item.call_id,
|
|
output: JSON.stringify(
|
|
this.stubResult || { ok: true, note: 'qa-harness stub result' },
|
|
),
|
|
},
|
|
});
|
|
this.pending.needContinuation = true;
|
|
}
|
|
if (msg.type === 'response.done') {
|
|
if (this.pending.needContinuation && this.pending.continuations < 4) {
|
|
this.pending.needContinuation = false;
|
|
this.pending.continuations += 1;
|
|
this.send({
|
|
type: 'response.create',
|
|
response: { output_modalities: ['text'] },
|
|
});
|
|
return;
|
|
}
|
|
const p = this.pending;
|
|
this.pending = null;
|
|
p.resolve(p.calls);
|
|
this.lastTurn = p;
|
|
}
|
|
if (msg.type === 'error') {
|
|
// Log-and-continue: a per-turn API hiccup should surface as that turn's
|
|
// result, not poison the batch. The turn resolves with what it has.
|
|
this.pending.errors.push(msg.error?.message || 'realtime error');
|
|
}
|
|
}
|
|
|
|
send(obj) {
|
|
this.evidence.write(
|
|
`${JSON.stringify({ at: new Date().toISOString(), dir: 'send', type: obj.type })}\n`,
|
|
);
|
|
this.ws.send(JSON.stringify(obj));
|
|
}
|
|
|
|
/** An inert system item (the app's notifyMapEvent shape), e.g. pointer_context. */
|
|
sendSystemItem(payload) {
|
|
this.send({
|
|
type: 'conversation.item.create',
|
|
item: {
|
|
type: 'message',
|
|
role: 'system',
|
|
content: [{ type: 'input_text', text: JSON.stringify(payload) }],
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Send one user text turn; resolve with the list of tool calls it produced. */
|
|
runTurn(text) {
|
|
return new Promise((resolve) => {
|
|
this.pending = {
|
|
calls: [],
|
|
messages: [],
|
|
callResponses: [],
|
|
continuations: 0,
|
|
needContinuation: false,
|
|
errors: [],
|
|
resolve,
|
|
};
|
|
const timer = setTimeout(() => {
|
|
if (this.pending) {
|
|
const p = this.pending;
|
|
this.pending = null;
|
|
p.resolve(p.calls);
|
|
}
|
|
}, 45000);
|
|
const origResolve = resolve;
|
|
this.pending.resolve = (v) => {
|
|
clearTimeout(timer);
|
|
origResolve(v);
|
|
};
|
|
this.send({
|
|
type: 'conversation.item.create',
|
|
item: {
|
|
type: 'message',
|
|
role: 'user',
|
|
content: [{ type: 'input_text', text }],
|
|
},
|
|
});
|
|
this.send({
|
|
type: 'response.create',
|
|
response: { output_modalities: ['text'] },
|
|
});
|
|
});
|
|
}
|
|
|
|
close() {
|
|
try {
|
|
this.ws?.close();
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
}
|
|
}
|
|
|
|
async function runRoutingLayer() {
|
|
console.log(
|
|
`\nLAYER 1 — routing assertions (budget ${TURN_BUDGET} model turns)`,
|
|
);
|
|
const logDir = path.join(ROOT, '.gev-logs', 'qa-voice-routing');
|
|
fs.mkdirSync(logDir, { recursive: true });
|
|
const evidence = fs.createWriteStream(
|
|
path.join(logDir, `run-${Date.now()}.jsonl`),
|
|
);
|
|
|
|
const list = PHRASES.filter(
|
|
(p) => !ONLY || p.phrase.toLowerCase().includes(ONLY.toLowerCase()),
|
|
);
|
|
let turnsUsed = 0;
|
|
|
|
for (let i = 0; i < list.length; i += PHRASES_PER_SESSION) {
|
|
if (turnsUsed >= TURN_BUDGET) {
|
|
list
|
|
.slice(i)
|
|
.forEach((p) =>
|
|
skipped(`route: "${p.phrase}"`, 'turn budget exhausted'),
|
|
);
|
|
break;
|
|
}
|
|
const batch = list.slice(i, i + PHRASES_PER_SESSION);
|
|
let session;
|
|
try {
|
|
const { value, model } = await mintSession();
|
|
session = new RoutingSession(value, model, evidence);
|
|
await session.connect();
|
|
} catch (e) {
|
|
batch.forEach((p) =>
|
|
report(
|
|
false,
|
|
`route: "${p.phrase}"`,
|
|
`session setup failed: ${e.message}`,
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
for (const p of batch) {
|
|
if (turnsUsed >= TURN_BUDGET) {
|
|
skipped(`route: "${p.phrase}"`, 'turn budget exhausted');
|
|
continue;
|
|
}
|
|
turnsUsed += 1;
|
|
let calls = [];
|
|
try {
|
|
calls = await session.runTurn(p.phrase);
|
|
} catch (e) {
|
|
report(false, `route: "${p.phrase}"`, `turn error: ${e.message}`);
|
|
continue;
|
|
}
|
|
recordSpeech(
|
|
p.phrase,
|
|
calls.map((c) => c.name),
|
|
session.lastTurn,
|
|
);
|
|
const grade = gradeCore(p, calls);
|
|
report(
|
|
grade.ok,
|
|
`route: "${p.phrase}" → ${expectLabel(p)}`,
|
|
grade.detail,
|
|
);
|
|
}
|
|
session.close();
|
|
}
|
|
evidence.end();
|
|
console.log(` routing turns used: ${turnsUsed}/${TURN_BUDGET}`);
|
|
summarizeSpeech(logDir);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════
|
|
// LAYER 1b — DEIXIS ROUTING (costs model turns)
|
|
// ════════════════════════════════════════════════════════════
|
|
//
|
|
// Point-and-ask phrases against the production instructions and tools. Each
|
|
// scenario runs in its own fresh session: a pointer_context item built by the
|
|
// app's own formatter lands first (exactly what the client sends at speech
|
|
// start), then the user's words. Asserts the model passes the 'pointer'
|
|
// sentinel (or referent:n) instead of guessing coordinates or names.
|
|
|
|
const AIRCRAFT_POINTER = pointerContextItem(
|
|
buildPointerSnapshot({
|
|
at: 'speech_start',
|
|
x: 912,
|
|
y: 388,
|
|
width: 1500,
|
|
height: 950,
|
|
pick: {
|
|
entity: {
|
|
layerId: 'flights',
|
|
id: 'a4f2c1',
|
|
label: 'UPS793',
|
|
kind: 'aircraft',
|
|
lat: 30.3121,
|
|
lon: -97.6013,
|
|
},
|
|
ground: { lat: 30.3, lon: -97.6 },
|
|
},
|
|
}),
|
|
);
|
|
const GROUND_POINTER = pointerContextItem(
|
|
buildPointerSnapshot({
|
|
at: 'speech_start',
|
|
x: 640,
|
|
y: 520,
|
|
width: 1500,
|
|
height: 950,
|
|
pick: {
|
|
entity: null,
|
|
ground: { lat: 30.2849, lon: -97.7341, heightM: 160 },
|
|
},
|
|
}),
|
|
);
|
|
const FRAMED_STUB = {
|
|
ok: true,
|
|
action: 'frame_overhead',
|
|
layerId: 'flights',
|
|
count: 3,
|
|
say: 'Framed 3 aircraft within 150 km.',
|
|
display: { title: '3 aircraft in frame' },
|
|
referents: [
|
|
{ n: 1, id: 'a11111', label: 'SWA1445', layerId: 'flights' },
|
|
{ n: 2, id: 'a22222', label: 'UPS793', layerId: 'flights' },
|
|
{ n: 3, id: 'a33333', label: 'AAL2231', layerId: 'flights' },
|
|
],
|
|
};
|
|
|
|
const pointerArg = (value) => String(value || '').toLowerCase() === 'pointer';
|
|
const DEIXIS_SCENARIOS = [
|
|
{
|
|
pointer: AIRCRAFT_POINTER,
|
|
phrase: 'Tell me about this',
|
|
check: (c) => c.name === 'get_entity_context' && pointerArg(c.args.scope),
|
|
want: 'get_entity_context{scope:pointer}',
|
|
},
|
|
{
|
|
pointer: AIRCRAFT_POINTER,
|
|
phrase: "What's that plane?",
|
|
check: (c) => c.name === 'get_entity_context' && pointerArg(c.args.scope),
|
|
want: 'get_entity_context{scope:pointer}',
|
|
},
|
|
{
|
|
pointer: AIRCRAFT_POINTER,
|
|
phrase: 'Track that one',
|
|
check: (c) =>
|
|
c.name === 'track_entity' &&
|
|
(pointerArg(c.args.query) || /a4f2c1|ups793/i.test(c.args.query || '')),
|
|
want: 'track_entity{query:pointer}',
|
|
},
|
|
{
|
|
pointer: GROUND_POINTER,
|
|
phrase: 'How many flights are around here?',
|
|
check: (c) => c.name === 'analyst_query' && pointerArg(c.args.scope?.kind),
|
|
want: 'analyst_query{scope.kind:pointer}',
|
|
},
|
|
{
|
|
pointer: GROUND_POINTER,
|
|
phrase: 'Take me there',
|
|
check: (c) => c.name === 'fly_to_location' && pointerArg(c.args.query),
|
|
want: 'fly_to_location{query:pointer}',
|
|
},
|
|
{
|
|
pointer: GROUND_POINTER,
|
|
phrase: 'Drop a pin right here',
|
|
check: (c) =>
|
|
c.name === 'annotate_map' &&
|
|
(c.args.annotations || []).some((a) => pointerArg(a.target)),
|
|
want: 'annotate_map{target:pointer}',
|
|
},
|
|
{
|
|
pointer: GROUND_POINTER,
|
|
phrase: 'What is this place?',
|
|
check: (c) => c.name === 'get_entity_context' && pointerArg(c.args.scope),
|
|
want: 'get_entity_context{scope:pointer}',
|
|
},
|
|
// Open mic / typed turns: the model only learns that something is pointed at.
|
|
{
|
|
pointer: {
|
|
type: 'pointer_context',
|
|
at: 'speech_start',
|
|
pointing: 'aircraft',
|
|
},
|
|
phrase: "What's that?",
|
|
check: (c) => c.name === 'get_entity_context' && pointerArg(c.args.scope),
|
|
want: 'get_entity_context{scope:pointer} (pointing:aircraft)',
|
|
},
|
|
{
|
|
pointer: {
|
|
type: 'pointer_context',
|
|
at: 'speech_start',
|
|
pointing: 'ground',
|
|
},
|
|
phrase: 'How many flights are around here?',
|
|
check: (c) => c.name === 'analyst_query' && pointerArg(c.args.scope?.kind),
|
|
want: 'analyst_query{scope.kind:pointer} (pointing:ground)',
|
|
},
|
|
{
|
|
pointer: {
|
|
type: 'pointer_context',
|
|
at: 'speech_start',
|
|
pointing: 'aircraft',
|
|
},
|
|
phrase: 'Follow that one',
|
|
check: (c) => c.name === 'track_entity' && pointerArg(c.args.query),
|
|
want: 'track_entity{query:pointer} (pointing:aircraft)',
|
|
},
|
|
// A named place wins over the pointer.
|
|
{
|
|
pointer: GROUND_POINTER,
|
|
phrase: 'How many flights are over Texas right now?',
|
|
check: (c) => c.name === 'analyst_query' && !pointerArg(c.args.scope?.kind),
|
|
want: 'analyst_query not pointer (named place)',
|
|
},
|
|
// No pointer_context: the model may still pass the sentinel (observed); the
|
|
// runner then falls back to scope auto, so only the tool choice is pinned.
|
|
{
|
|
pointer: null,
|
|
phrase: 'Tell me about this',
|
|
check: (c) => c.name === 'get_entity_context',
|
|
want: 'get_entity_context (runner falls back to auto)',
|
|
},
|
|
// Referents: a numbered list, then "the second one".
|
|
{
|
|
setup: { phrase: 'Frame the aircraft near us', stub: FRAMED_STUB },
|
|
pointer: null,
|
|
phrase: 'Track the second one',
|
|
check: (c) =>
|
|
c.name === 'track_entity' &&
|
|
(c.args.referent === 2 || /ups793|a22222/i.test(c.args.query || '')),
|
|
want: 'track_entity{referent:2}',
|
|
},
|
|
];
|
|
|
|
async function runDeixisRoutingLayer() {
|
|
console.log(
|
|
`\nLAYER 1b — deixis routing (budget ${TURN_BUDGET} model turns)`,
|
|
);
|
|
const logDir = path.join(ROOT, '.gev-logs', 'qa-voice-routing');
|
|
fs.mkdirSync(logDir, { recursive: true });
|
|
const evidence = fs.createWriteStream(
|
|
path.join(logDir, `deixis-${Date.now()}.jsonl`),
|
|
);
|
|
let turnsUsed = 0;
|
|
let referentUse = 0;
|
|
for (const scenario of DEIXIS_SCENARIOS) {
|
|
const where = !scenario.pointer
|
|
? 'no pointer'
|
|
: scenario.pointer.pointing
|
|
? `pointing ${scenario.pointer.pointing}`
|
|
: scenario.pointer.target
|
|
? scenario.pointer.target.label
|
|
: 'ground';
|
|
const label = `deixis route: [${where}] "${scenario.phrase}" → ${scenario.want}`;
|
|
const cost = scenario.setup ? 2 : 1;
|
|
if (turnsUsed + cost > TURN_BUDGET) {
|
|
skipped(label, 'turn budget exhausted');
|
|
continue;
|
|
}
|
|
let session;
|
|
try {
|
|
const { value, model } = await mintSession();
|
|
session = new RoutingSession(value, model, evidence);
|
|
await session.connect();
|
|
} catch (e) {
|
|
report(false, label, `session setup failed: ${e.message}`);
|
|
continue;
|
|
}
|
|
try {
|
|
if (scenario.setup) {
|
|
turnsUsed += 1;
|
|
session.stubResult = scenario.setup.stub;
|
|
await session.runTurn(scenario.setup.phrase);
|
|
session.stubResult = null;
|
|
}
|
|
if (scenario.pointer) session.sendSystemItem(scenario.pointer);
|
|
turnsUsed += 1;
|
|
const calls = await session.runTurn(scenario.phrase);
|
|
const hit = calls.find(scenario.check);
|
|
if (hit?.args?.referent !== undefined) referentUse += 1;
|
|
report(
|
|
Boolean(hit),
|
|
label,
|
|
`called: ${
|
|
calls
|
|
.map((c) => `${c.name}${JSON.stringify(c.args)}`)
|
|
.join(' ')
|
|
.slice(0, 220) || '(none)'
|
|
}`,
|
|
);
|
|
} catch (e) {
|
|
report(false, label, `turn error: ${e.message}`);
|
|
} finally {
|
|
session.close();
|
|
}
|
|
}
|
|
evidence.end();
|
|
console.log(
|
|
` deixis routing turns used: ${turnsUsed}/${TURN_BUDGET}; referent:n used ${referentUse}x`,
|
|
);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════
|
|
// LAYER 1c — DEIXIS LIVE (in-app text turns; costs model turns)
|
|
// ════════════════════════════════════════════════════════════
|
|
//
|
|
// The whole loop in the real app: a synthetic pointer on the live canvas, a
|
|
// real Realtime session (push-to-talk mode, so the fake microphone stays
|
|
// muted), a typed turn, the model's tool call, the runner's resolution and
|
|
// the tool result — observed through the session's own events.
|
|
|
|
async function runDeixisLiveLayer() {
|
|
console.log('\nLAYER 1c — deixis live (in-app text turns)');
|
|
const browser = await puppeteer.launch({
|
|
headless: HEADFUL ? false : 'new',
|
|
...(CHROME_EXECUTABLE ? { executablePath: CHROME_EXECUTABLE } : {}),
|
|
args: [
|
|
'--no-sandbox',
|
|
'--disable-setuid-sandbox',
|
|
...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']),
|
|
'--disable-dev-shm-usage',
|
|
'--disable-background-timer-throttling',
|
|
'--disable-renderer-backgrounding',
|
|
'--use-fake-device-for-media-stream',
|
|
'--use-fake-ui-for-media-stream',
|
|
'--autoplay-policy=no-user-gesture-required',
|
|
'--window-size=1500,950',
|
|
],
|
|
protocolTimeout: 180000,
|
|
});
|
|
const appUrl = new URL(APP_URL);
|
|
appUrl.searchParams.set('welcome', '0');
|
|
await browser
|
|
.defaultBrowserContext()
|
|
.overridePermissions(appUrl.origin, ['microphone']);
|
|
const page = await browser.newPage();
|
|
await page.setViewport({ width: 1500, height: 950 });
|
|
const settle = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
const run = (name, args) =>
|
|
page.evaluate(
|
|
(n, a) =>
|
|
Promise.resolve(window.__gevVoiceCommands.runner(n, a)).catch((e) => ({
|
|
ok: false,
|
|
error: String(e?.message || e),
|
|
})),
|
|
name,
|
|
args,
|
|
);
|
|
let turnsUsed = 0;
|
|
try {
|
|
await page.goto(appUrl.href, {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 60000,
|
|
});
|
|
await page.waitForFunction(
|
|
() => window.__godsEyeView?.viewer && window.__gevVoiceCommands?.session,
|
|
{ timeout: 120000, polling: 250 },
|
|
);
|
|
await page.evaluate(() =>
|
|
window.__godsEyeView.viewer.camera.cancelFlight(),
|
|
);
|
|
await run('fly_to_location', { query: 'Austin, Texas' });
|
|
await settle(9000);
|
|
await run('set_layer_visibility', { layerId: 'flights', enabled: true });
|
|
await settle(40000);
|
|
await run('frame_overhead', { target: 'flights' });
|
|
await settle(7000);
|
|
await page.evaluate(() => {
|
|
window.__qaEvents = [];
|
|
window.__gevVoiceCommands.session.subscribe((event) => {
|
|
window.__qaEvents.push({
|
|
type: event.type,
|
|
name: event.name || null,
|
|
state: event.state || null,
|
|
args: event.arguments || null,
|
|
result: event.result
|
|
? {
|
|
ok: event.result.ok,
|
|
error: event.result.error || null,
|
|
say: event.result.say ?? null,
|
|
resolvedFrom: event.result.resolvedFrom || null,
|
|
selectedId: event.result.selected?.id || null,
|
|
scopeLabel: event.result.scopeLabel || null,
|
|
label: event.result.label || null,
|
|
}
|
|
: null,
|
|
text: event.type === 'transcript' && event.final ? event.text : null,
|
|
role: event.role || null,
|
|
});
|
|
});
|
|
return window.__gevVoiceCommands.session.start({ pushToTalk: true });
|
|
});
|
|
await page.waitForFunction(
|
|
() => window.__gevVoiceCommands.status === 'listening',
|
|
{ timeout: 45000, polling: 250 },
|
|
);
|
|
|
|
// The pointer moves and the text is sent in one step, as when someone
|
|
// points and types: typed turns count only a pointer moved in the last
|
|
// few seconds.
|
|
const turn = async (text, point = null) => {
|
|
turnsUsed += 1;
|
|
const from = await page.evaluate(() => window.__qaEvents.length);
|
|
await page.evaluate(
|
|
(t, p) => {
|
|
let at = p;
|
|
if (p?.aircraftId) {
|
|
// Aircraft move: project the contact's live position in this step.
|
|
const { viewer, dataManager } = window.__godsEyeView;
|
|
// The billboard position, which is what the pick hits.
|
|
const found = (
|
|
dataManager.layers
|
|
.get('flights')
|
|
?.module?.getAllPositions?.(3000) || []
|
|
).find((e) => e.id === p.aircraftId);
|
|
viewer.scene.render();
|
|
at = found?.position
|
|
? viewer.scene.cartesianToCanvasCoordinates(found.position)
|
|
: null;
|
|
}
|
|
if (at) window.__gevVoiceCommands.pointer.moveTo(at.x, at.y);
|
|
window.__gevVoiceCommands.sendTextCommand(t);
|
|
},
|
|
text,
|
|
point,
|
|
);
|
|
// Done when a reply completes after the last tool result (or no tool).
|
|
const deadline = Date.now() + 90000;
|
|
let events = [];
|
|
while (Date.now() < deadline) {
|
|
await settle(500);
|
|
events = await page.evaluate((i) => window.__qaEvents.slice(i), from);
|
|
const lastResult = events
|
|
.map((e) => e.type)
|
|
.lastIndexOf('action-result');
|
|
const lastCall = events.map((e) => e.type).lastIndexOf('action-call');
|
|
const answered = events.some(
|
|
(e, i) =>
|
|
e.type === 'transcript' &&
|
|
e.role === 'assistant' &&
|
|
e.text &&
|
|
i > lastResult,
|
|
);
|
|
if (answered && lastResult >= lastCall) break;
|
|
}
|
|
return events;
|
|
};
|
|
const summarize = (events) =>
|
|
events
|
|
.filter((e) => e.type === 'action-call' || e.type === 'action-result')
|
|
.map((e) =>
|
|
e.type === 'action-call'
|
|
? `${e.name}${JSON.stringify(e.args)}`
|
|
: `→${JSON.stringify(e.result)}`,
|
|
)
|
|
.join(' ')
|
|
.slice(0, 320);
|
|
|
|
const aircraft = await findPickableAircraft(page);
|
|
if (!aircraft) {
|
|
skipped(
|
|
'deixis live: aircraft scenarios',
|
|
'no pickable aircraft on screen',
|
|
);
|
|
} else {
|
|
let events = await turn('Tell me about this', {
|
|
aircraftId: aircraft.id,
|
|
});
|
|
let call = events.find(
|
|
(e) => e.type === 'action-call' && e.name === 'get_entity_context',
|
|
);
|
|
let result = events.find(
|
|
(e) => e.type === 'action-result' && e.name === 'get_entity_context',
|
|
);
|
|
report(
|
|
call?.args?.scope === 'pointer' &&
|
|
result?.result?.selectedId === aircraft.id,
|
|
`deixis live: [${aircraft.label}] "Tell me about this" → the pointed aircraft`,
|
|
summarize(events),
|
|
);
|
|
if (SHOT_DIR) {
|
|
const dir = path.resolve(ROOT, SHOT_DIR);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
await page.screenshot({
|
|
path: path.join(dir, 'live-tell-me-about-this.png'),
|
|
});
|
|
}
|
|
events = await turn('Track that one', { aircraftId: aircraft.id });
|
|
result = events.find(
|
|
(e) => e.type === 'action-result' && e.name === 'track_entity',
|
|
);
|
|
report(
|
|
// By the pointer, or by the name the previous answer just gave it.
|
|
result?.result?.ok === true &&
|
|
(result.result.resolvedFrom?.source === 'pointer' ||
|
|
result.result.label === aircraft.label),
|
|
`deixis live: [${aircraft.label}] "Track that one" → tracks it`,
|
|
summarize(events),
|
|
);
|
|
await run('stop_tracking', {});
|
|
await settle(3000);
|
|
}
|
|
|
|
const size = await page.evaluate(() => ({
|
|
w: window.__godsEyeView.viewer.scene.canvas.clientWidth,
|
|
h: window.__godsEyeView.viewer.scene.canvas.clientHeight,
|
|
}));
|
|
const events = await turn('How many flights are around here?', {
|
|
x: size.w * 0.3,
|
|
y: size.h * 0.62,
|
|
});
|
|
const result = events.find(
|
|
(e) => e.type === 'action-result' && e.name === 'analyst_query',
|
|
);
|
|
report(
|
|
result?.result?.resolvedFrom?.source === 'pointer' &&
|
|
/within/i.test(result?.result?.scopeLabel || ''),
|
|
'deixis live: [ground] "How many flights are around here?" → radius at the pointer',
|
|
summarize(events),
|
|
);
|
|
} catch (e) {
|
|
report(
|
|
false,
|
|
'deixis live: layer crashed',
|
|
String(e?.message || e).slice(0, 200),
|
|
);
|
|
} finally {
|
|
try {
|
|
await page.evaluate(() => window.__gevVoiceCommands?.session?.stop());
|
|
} catch {
|
|
/* closing */
|
|
}
|
|
await browser.close();
|
|
console.log(` deixis live turns used: ${turnsUsed}`);
|
|
}
|
|
}
|
|
|
|
// Speech stats: informational, never pass/fail. Slow tools are the ones whose
|
|
// latency justifies a spoken plan; instant ones should answer after the call.
|
|
const SLOW_TOOLS = new Set([
|
|
'annotate_map',
|
|
'fly_to_location',
|
|
'select_nearest_aircraft',
|
|
'analyst_query',
|
|
'fly_route',
|
|
'frame_overhead',
|
|
]);
|
|
const speechTurns = [];
|
|
function recordSpeech(phrase, names, turn) {
|
|
if (!turn) return;
|
|
const firstCallResponse = turn.callResponses.length
|
|
? Math.min(...turn.callResponses)
|
|
: null;
|
|
const preamble = turn.messages
|
|
.filter(
|
|
(m) =>
|
|
firstCallResponse !== null &&
|
|
m.response === firstCallResponse &&
|
|
m.callsBefore === 0,
|
|
)
|
|
.map((m) => m.text)
|
|
.join(' ');
|
|
const answer = turn.messages
|
|
.filter(
|
|
(m) =>
|
|
!(
|
|
firstCallResponse !== null &&
|
|
m.response === firstCallResponse &&
|
|
m.callsBefore === 0
|
|
),
|
|
)
|
|
.map((m) => m.text)
|
|
.join(' ');
|
|
speechTurns.push({
|
|
phrase,
|
|
tools: names,
|
|
slow: names.some((n) => SLOW_TOOLS.has(n)),
|
|
preamble: preamble || null,
|
|
answer,
|
|
answerWords: countWords(answer),
|
|
preambleWords: countWords(preamble),
|
|
hedges: findHedges(`${preamble} ${answer}`),
|
|
});
|
|
}
|
|
function summarizeSpeech(logDir) {
|
|
const toolTurns = speechTurns.filter((t) => t.tools.length);
|
|
const slow = toolTurns.filter((t) => t.slow);
|
|
const instant = toolTurns.filter((t) => !t.slow);
|
|
const rate = (list) =>
|
|
list.length
|
|
? `${list.filter((t) => t.preamble).length}/${list.length}`
|
|
: '0/0';
|
|
const words = speechTurns.map((t) => t.answerWords).sort((a, b) => a - b);
|
|
const summary = {
|
|
turns: speechTurns.length,
|
|
preambleSlow: rate(slow),
|
|
preambleInstant: rate(instant),
|
|
answerWordsP50: words[Math.floor(words.length / 2)] ?? null,
|
|
answerWordsMax: words.at(-1) ?? null,
|
|
answerWordsTotal: words.reduce((a, b) => a + b, 0),
|
|
hedgeHits: speechTurns.reduce((sum, t) => sum + t.hedges.length, 0),
|
|
preambleWordsMax: Math.max(0, ...speechTurns.map((t) => t.preambleWords)),
|
|
spokenWordsTotal: speechTurns.reduce(
|
|
(sum, t) => sum + t.answerWords + t.preambleWords,
|
|
0,
|
|
),
|
|
};
|
|
fs.writeFileSync(
|
|
path.join(logDir, `speech-${Date.now()}.json`),
|
|
JSON.stringify({ summary, turns: speechTurns }, null, 2),
|
|
);
|
|
console.log(
|
|
` speech: preamble slow ${summary.preambleSlow}, instant ${summary.preambleInstant}; answer words p50 ${summary.answerWordsP50}, max ${summary.answerWordsMax}, total ${summary.answerWordsTotal}; preamble words max ${summary.preambleWordsMax}; all spoken words ${summary.spokenWordsTotal}; hedge hits ${summary.hedgeHits}`,
|
|
);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════
|
|
// LAYER 2 — BEHAVIOR (runner-driven, no model)
|
|
// ════════════════════════════════════════════════════════════
|
|
|
|
/** An on-screen aircraft whose billboard really answers a pick, or null. */
|
|
function findPickableAircraft(page) {
|
|
return page.evaluate(() => {
|
|
const { viewer, dataManager } = window.__godsEyeView;
|
|
const module = dataManager.layers.get('flights')?.module;
|
|
const canvas = viewer.scene.canvas;
|
|
const eye = viewer.camera.positionWC;
|
|
viewer.scene.render();
|
|
let picks = 0;
|
|
const candidates = [];
|
|
for (const entry of module?.getAllPositions?.(3000) || []) {
|
|
// Horizon test: far-side contacts still project onto the canvas.
|
|
const p = entry.position;
|
|
if (eye.x * p.x + eye.y * p.y + eye.z * p.z < 6356000 ** 2) continue;
|
|
const at = viewer.scene.cartesianToCanvasCoordinates(entry.position);
|
|
if (
|
|
!at ||
|
|
at.x < 60 ||
|
|
at.y < 60 ||
|
|
at.x > canvas.clientWidth - 60 ||
|
|
at.y > canvas.clientHeight - 240
|
|
)
|
|
continue;
|
|
candidates.push({
|
|
entry,
|
|
at,
|
|
d: Math.hypot(
|
|
at.x - canvas.clientWidth / 2,
|
|
at.y - canvas.clientHeight * 0.45,
|
|
),
|
|
});
|
|
}
|
|
// Nearest the middle first: clear of the dock and panels.
|
|
candidates.sort((a, b) => a.d - b.d);
|
|
for (const { entry, at } of candidates) {
|
|
if (++picks > 80) break;
|
|
const picked = viewer.scene.pick(at);
|
|
if (picked?.id === entry.id || picked?.primitive?.id === entry.id)
|
|
return { id: entry.id, label: entry.label, x: at.x, y: at.y };
|
|
}
|
|
return null;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Point-and-ask behavior (no model): place a synthetic pointer through the
|
|
* public tracker, start a turn the way speech start does, and drive the real
|
|
* runner with the 'pointer' sentinel and referent:n. The pick, reticle and
|
|
* card chip are the product's own.
|
|
*/
|
|
async function runDeixisBehavior(page, run, settle) {
|
|
const shot = async (name) => {
|
|
if (!SHOT_DIR) return;
|
|
// A backgrounded headful window freezes CSS animation timelines.
|
|
await page.bringToFront();
|
|
const dir = path.resolve(ROOT, SHOT_DIR);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
await page.screenshot({ path: path.join(dir, name) });
|
|
};
|
|
const beginAt = (x, y, said = null) =>
|
|
page.evaluate(
|
|
async (px, py, text) => {
|
|
const voice = window.__gevVoiceCommands;
|
|
if (text) {
|
|
// What the session emits for a spoken turn: a new turn, then the caption.
|
|
voice.emitSessionEvent({
|
|
type: 'interruption',
|
|
reason: 'user-speech',
|
|
});
|
|
voice.emitSessionEvent({
|
|
type: 'transcript',
|
|
role: 'user',
|
|
text,
|
|
final: true,
|
|
});
|
|
}
|
|
voice.pointer.moveTo(px, py);
|
|
// A Space hold: the explicit pointing gesture shows the reticle and chip.
|
|
voice.pointer.hold('keydown');
|
|
const snapshot = voice.beginPointerTurn('keydown');
|
|
voice.pointer.releaseHold();
|
|
// The voice card and the reticle render on Vue's next flush.
|
|
await Promise.resolve();
|
|
const chip = document.getElementById('gev-voice-card-pointer');
|
|
const reticle = document.querySelector('.gev-pointer-reticle');
|
|
return {
|
|
snapshot,
|
|
chipVisible: Boolean(chip && !chip.hidden),
|
|
chipKind:
|
|
document.getElementById('gev-voice-card-pointer-kind')
|
|
?.textContent || '',
|
|
chipText:
|
|
document.getElementById('gev-voice-card-pointer-text')
|
|
?.textContent || '',
|
|
reticleVisible: Boolean(reticle && !reticle.hidden),
|
|
};
|
|
},
|
|
x,
|
|
y,
|
|
said,
|
|
);
|
|
const showResult = (name, result) =>
|
|
page.evaluate(
|
|
(n, res) => {
|
|
// The session publishes every runner result to the card this way.
|
|
window.__gevVoiceCommands.emitSessionEvent({
|
|
type: 'action-call',
|
|
name: n,
|
|
arguments: {},
|
|
});
|
|
window.__gevVoiceCommands.emitSessionEvent({
|
|
type: 'action-result',
|
|
name: n,
|
|
result: res,
|
|
});
|
|
},
|
|
name,
|
|
result,
|
|
);
|
|
|
|
// D1 — an aircraft under the pointer: find one whose billboard really picks.
|
|
const aircraft = await findPickableAircraft(page);
|
|
if (!aircraft) {
|
|
skipped(
|
|
'deixis: pointer on an aircraft',
|
|
'no pickable aircraft on screen right now',
|
|
);
|
|
} else {
|
|
const turn = await beginAt(aircraft.x, aircraft.y, 'Tell me about this');
|
|
report(
|
|
turn.snapshot?.entity?.id === aircraft.id &&
|
|
turn.chipVisible &&
|
|
turn.chipKind === 'THIS' &&
|
|
turn.reticleVisible,
|
|
'deixis: pointer on an aircraft resolves "this" with chip and reticle',
|
|
`entity=${turn.snapshot?.entity?.id}/${turn.snapshot?.entity?.label} expected=${aircraft.id} chip=${turn.chipKind}:${turn.chipText} reticle=${turn.reticleVisible}`,
|
|
);
|
|
await shot('aircraft-reticle-chip.png');
|
|
if (SHOT_DIR) {
|
|
const clip = {
|
|
x: Math.max(0, aircraft.x - 90),
|
|
y: Math.max(0, aircraft.y - 90),
|
|
width: 180,
|
|
height: 180,
|
|
};
|
|
await page.screenshot({
|
|
path: path.join(
|
|
path.resolve(ROOT, SHOT_DIR),
|
|
'aircraft-reticle-zoom.png',
|
|
),
|
|
clip,
|
|
});
|
|
}
|
|
const context = await run('get_entity_context', { scope: 'pointer' });
|
|
report(
|
|
context?.ok === true &&
|
|
context.scope === 'pointer' &&
|
|
context.selected?.id === aircraft.id &&
|
|
Boolean(context.identityLine),
|
|
'deixis: "tell me about this" describes the pointed aircraft',
|
|
`selected=${context?.selected?.id} identityLine=${JSON.stringify(context?.identityLine)}`,
|
|
);
|
|
await showResult('get_entity_context', context);
|
|
await page.evaluate(
|
|
(line) =>
|
|
window.__gevVoiceCommands.emitSessionEvent({
|
|
type: 'transcript',
|
|
role: 'assistant',
|
|
text: line,
|
|
final: true,
|
|
}),
|
|
context?.identityLine || '',
|
|
);
|
|
await settle(400);
|
|
await shot('result-card.png');
|
|
const tracked = await run('track_entity', { query: 'pointer' });
|
|
const trackedId = await page.evaluate(() => {
|
|
const info = window.__godsEyeView.dataManager.layers
|
|
.get('flights')
|
|
?.module?.getTrackedInfo?.();
|
|
return info?.icao24 || info?.id || null;
|
|
});
|
|
report(
|
|
tracked?.ok === true &&
|
|
tracked.resolvedFrom?.source === 'pointer' &&
|
|
(trackedId === aircraft.id || trackedId === null),
|
|
'deixis: "track that one" follows the pointed aircraft',
|
|
`ok=${tracked?.ok} label=${tracked?.label} trackedId=${trackedId} error=${tracked?.error || ''}`,
|
|
);
|
|
await run('stop_tracking', {});
|
|
await settle(2500);
|
|
}
|
|
|
|
// D2 — bare ground: the chip gives coordinates and "around here" is a radius.
|
|
const size = await page.evaluate(() => {
|
|
const canvas = window.__godsEyeView.viewer.scene.canvas;
|
|
return { w: canvas.clientWidth, h: canvas.clientHeight };
|
|
});
|
|
let groundTurn = null;
|
|
for (const [fx, fy] of [
|
|
[0.3, 0.62],
|
|
[0.7, 0.62],
|
|
[0.5, 0.7],
|
|
[0.2, 0.5],
|
|
]) {
|
|
groundTurn = await beginAt(size.w * fx, size.h * fy, "What's here?");
|
|
if (groundTurn.snapshot?.target === 'ground') break;
|
|
}
|
|
report(
|
|
groundTurn?.snapshot?.target === 'ground' &&
|
|
groundTurn.chipKind === 'HERE' &&
|
|
/-?\d+\.\d\d, -?\d+\.\d\d/.test(groundTurn.chipText) &&
|
|
groundTurn.reticleVisible,
|
|
'deixis: pointer on bare ground resolves "here" to coordinates',
|
|
`target=${groundTurn?.snapshot?.target} chip=${groundTurn?.chipKind}:${groundTurn?.chipText} reticle=${groundTurn?.reticleVisible}`,
|
|
);
|
|
await shot('ground-reticle-chip.png');
|
|
// The 512 px crop a ground-only point sends the model (one retained image).
|
|
const captured = await page.evaluate(
|
|
(px) => window.__gevVoiceCommands._viewport.capturePointer(px),
|
|
groundTurn?.snapshot?.screenPx || null,
|
|
);
|
|
const crop = captured?.dataUrl || null;
|
|
const cropInspection = await inspectPointerCrop(
|
|
captured,
|
|
groundTurn?.snapshot?.screenPx,
|
|
size,
|
|
).catch(() => null);
|
|
report(
|
|
typeof crop === 'string' &&
|
|
crop.startsWith('data:image/jpeg') &&
|
|
crop.length > 4000 &&
|
|
captured.rect?.w > 0 &&
|
|
cropInspection?.width === 512 &&
|
|
cropInspection?.height === 512 &&
|
|
cropInspection?.ringPixels >= 20 &&
|
|
cropInspection?.usableRatio >= 0.05,
|
|
'deixis: a ground point yields a 512 px crop with the ring',
|
|
`bytes~${crop ? Math.round((crop.length * 3) / 4 / 1024) : 0} KB rect=${JSON.stringify(captured?.rect)} inspection=${JSON.stringify(cropInspection)}`,
|
|
);
|
|
if (SHOT_DIR && crop) {
|
|
fs.writeFileSync(
|
|
path.join(path.resolve(ROOT, SHOT_DIR), 'ground-crop.jpg'),
|
|
Buffer.from(crop.split(',')[1], 'base64'),
|
|
);
|
|
}
|
|
const where = await run('get_entity_context', { scope: 'pointer' });
|
|
report(
|
|
where?.ok === true &&
|
|
where.scope === 'pointer' &&
|
|
Number.isFinite(where.pointer?.latitude ?? where.selected?.latitude),
|
|
'deixis: "what is here" reads context at the pointer, not the view centre',
|
|
`scope=${where?.scope} pointer=${JSON.stringify(where?.pointer)}`,
|
|
);
|
|
const around = await run('analyst_query', {
|
|
layers: ['flights'],
|
|
scope: { kind: 'pointer' },
|
|
});
|
|
report(
|
|
around?.ok !== false &&
|
|
around?.resolvedFrom?.source === 'pointer' &&
|
|
/within/i.test(around?.scopeLabel || ''),
|
|
'deixis: "how many flights around here" counts a radius at the pointer',
|
|
`count=${around?.count} scope=${around?.scopeLabel} km=${around?.resolvedFrom?.km}`,
|
|
);
|
|
const pin = await run('annotate_map', {
|
|
annotations: [{ type: 'pin', target: 'pointer', label: 'Here' }],
|
|
});
|
|
report(
|
|
pin?.ok === true && pin.drawn >= 1,
|
|
'deixis: "drop a pin here" marks the pointed spot',
|
|
`drawn=${pin?.drawn} failed=${JSON.stringify(pin?.failedLabels)}`,
|
|
);
|
|
|
|
// D3 — referents: "track the second one" after a numbered list.
|
|
const framed = await run('frame_overhead', { target: 'flights' });
|
|
await settle(5000);
|
|
const refs = Array.isArray(framed?.referents) ? framed.referents : [];
|
|
if (refs.length < 2) {
|
|
skipped(
|
|
'deixis: "track the second one"',
|
|
`only ${refs.length} referents in frame`,
|
|
);
|
|
} else {
|
|
const second = await run('track_entity', {
|
|
query: 'the second one',
|
|
referent: 2,
|
|
});
|
|
report(
|
|
second?.ok === true &&
|
|
second.resolvedFrom?.source === 'referent' &&
|
|
second.label === refs[1].label,
|
|
'deixis: "track the second one" follows referent 2',
|
|
`label=${second?.label} expected=${refs[1].label}`,
|
|
);
|
|
await run('stop_tracking', {});
|
|
const missing = await run('track_entity', { query: 'x', referent: 40 });
|
|
report(
|
|
missing?.ok === false && /no item 40/.test(missing?.error || ''),
|
|
'deixis: an out-of-range referent is a plain refusal',
|
|
missing?.error,
|
|
);
|
|
}
|
|
|
|
// D4 — a stale pointer is never "this".
|
|
const stale = await page.evaluate(() => {
|
|
const voice = window.__gevVoiceCommands;
|
|
voice.pointer.element.dispatchEvent(new PointerEvent('pointerleave'));
|
|
const snapshot = voice.beginPointerTurn('text');
|
|
const chip = document.getElementById('gev-voice-card-pointer');
|
|
return {
|
|
fresh: Boolean(snapshot?.fresh),
|
|
chipHidden: Boolean(chip?.hidden),
|
|
};
|
|
});
|
|
const refused = await run('track_entity', { query: 'pointer' });
|
|
report(
|
|
!stale.fresh &&
|
|
stale.chipHidden &&
|
|
refused?.ok === false &&
|
|
/Nothing is under the pointer/.test(refused?.error || ''),
|
|
'deixis: a pointer off the canvas is refused, not guessed',
|
|
`fresh=${stale.fresh} chipHidden=${stale.chipHidden} error=${refused?.error}`,
|
|
);
|
|
}
|
|
|
|
async function runBehaviorLayer() {
|
|
console.log('\nLAYER 2 — behavior drives (runner(), no model)');
|
|
try {
|
|
const res = await fetch(APP_URL);
|
|
if (!res.ok) throw new Error(String(res.status));
|
|
} catch (e) {
|
|
console.error(
|
|
`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`,
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
const browser = await puppeteer.launch({
|
|
headless: HEADFUL ? false : 'new',
|
|
...(CHROME_EXECUTABLE ? { executablePath: CHROME_EXECUTABLE } : {}),
|
|
args: [
|
|
'--no-sandbox',
|
|
'--disable-setuid-sandbox',
|
|
...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']),
|
|
'--disable-dev-shm-usage',
|
|
'--disable-web-security',
|
|
'--disable-background-timer-throttling',
|
|
'--disable-renderer-backgrounding',
|
|
'--window-size=1500,950',
|
|
],
|
|
protocolTimeout: 180000,
|
|
});
|
|
const page = await browser.newPage();
|
|
await page.setViewport({ width: 1500, height: 950 });
|
|
page.on('pageerror', (e) =>
|
|
console.log(` [page error] ${String(e).slice(0, 140)}`),
|
|
);
|
|
// Voice must not reach Overpass unless an operator configured it: count
|
|
// Overpass queries through the app and any direct request to an Overpass
|
|
// host (the status probe is not a query).
|
|
const overpassRequests = [];
|
|
const packTimes = [];
|
|
const requestStarted = new Map();
|
|
page.on('request', (request) => {
|
|
requestStarted.set(request, Date.now());
|
|
const target = new URL(request.url());
|
|
const viaApp =
|
|
target.origin === new URL(APP_URL).origin &&
|
|
target.pathname.startsWith('/api/overpass') &&
|
|
target.pathname !== '/api/overpass/status';
|
|
if (viaApp || /overpass/i.test(target.hostname))
|
|
overpassRequests.push(
|
|
`${request.method()} ${target.origin}${target.pathname}`,
|
|
);
|
|
});
|
|
page.on('requestfinished', (request) => {
|
|
if (/us_census_places|wof_neighborhoods/.test(request.url()))
|
|
packTimes.push({
|
|
file: request.url().split('/').pop().split('?')[0],
|
|
ms: Date.now() - (requestStarted.get(request) ?? Date.now()),
|
|
});
|
|
requestStarted.delete(request);
|
|
});
|
|
const resolverLog = [];
|
|
page.on('console', (message) => {
|
|
const text = message.text();
|
|
if (text.startsWith('[Resolver]')) resolverLog.push(text);
|
|
});
|
|
|
|
try {
|
|
// The point-and-ask block skips the first-run launcher so its evidence
|
|
// screenshots show the map; the full run keeps the historical URL.
|
|
const behaviorUrl = new URL(APP_URL);
|
|
if (DEIXIS_ONLY || BEHAVIOR === 'acceptance')
|
|
behaviorUrl.searchParams.set('welcome', '0');
|
|
await page.goto(behaviorUrl.href, {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 60000,
|
|
});
|
|
await page.waitForFunction(
|
|
() =>
|
|
window.__godsEyeView?.viewer &&
|
|
window.__gevVoiceCommands?.runner &&
|
|
window.__gevAnnotations,
|
|
{ timeout: 120000, polling: 250 },
|
|
);
|
|
// House rule: the intro flight clobbers teleports issued mid-flight.
|
|
await page.evaluate(() =>
|
|
window.__godsEyeView.viewer.camera.cancelFlight(),
|
|
);
|
|
|
|
const run = (name, args) =>
|
|
page.evaluate(
|
|
(n, a) =>
|
|
Promise.resolve(window.__gevVoiceCommands.runner(n, a)).catch(
|
|
(e) => ({
|
|
ok: false,
|
|
error: String(e?.message || e),
|
|
_threw: true,
|
|
}),
|
|
),
|
|
name,
|
|
args,
|
|
);
|
|
const camState = () =>
|
|
page.evaluate(() => {
|
|
const c = window.__godsEyeView.viewer.camera;
|
|
const p = c.positionCartographic;
|
|
return {
|
|
lat: (p.latitude * 180) / Math.PI,
|
|
lon: (p.longitude * 180) / Math.PI,
|
|
altKm: p.height / 1000,
|
|
pitchDeg: (c.pitch * 180) / Math.PI,
|
|
};
|
|
});
|
|
const settle = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
if (BEHAVIOR === 'outlines') {
|
|
await runBundledOutlineScenarios({
|
|
page,
|
|
run,
|
|
settle,
|
|
overpassRequests,
|
|
resolverLog,
|
|
packTimes,
|
|
});
|
|
return;
|
|
}
|
|
if (BEHAVIOR === 'acceptance') {
|
|
await runOutlineAcceptance({ page, run, settle, overpassRequests });
|
|
return;
|
|
}
|
|
|
|
// (1) fly_to_location lands near the target. Annotations follow IMMEDIATELY
|
|
// while the camera is local — the resolver's proximity gate rejects far
|
|
// targets BY DESIGN (annotating Austin from over the Alps must fail).
|
|
let r = await run('fly_to_location', { query: 'Austin, Texas' });
|
|
await settle(9000);
|
|
let cam = await camState();
|
|
const dAustin = Math.hypot(cam.lat - 30.2672, cam.lon + 97.7431);
|
|
report(
|
|
r?.ok !== false && dAustin < 1.2,
|
|
'behavior: fly_to_location Austin lands nearby',
|
|
`cam=(${cam.lat.toFixed(3)},${cam.lon.toFixed(3)}) Δ=${dAustin.toFixed(2)}° alt=${cam.altKm.toFixed(1)}km`,
|
|
);
|
|
|
|
// Radio voice control selects by semantic category + place without moving
|
|
// the just-established Austin camera, then preserves explicit volume and
|
|
// transport commands through the public layer surface.
|
|
// `fly_to_location` may finish its promise just before Cesium drains the
|
|
// final tween frame. Cancel that already-arrived flight so the assertion
|
|
// measures Radio's camera ownership, not residual navigation motion.
|
|
await page.evaluate(() =>
|
|
window.__godsEyeView.viewer.camera.cancelFlight(),
|
|
);
|
|
await settle(250);
|
|
const radioCameraBefore = await camState();
|
|
r = await run('control_radio', {
|
|
action: 'select',
|
|
category: 'news',
|
|
locationId: 'austin',
|
|
});
|
|
const radioCameraAfter = await camState();
|
|
const radioCameraDelta = Math.hypot(
|
|
radioCameraAfter.lat - radioCameraBefore.lat,
|
|
radioCameraAfter.lon - radioCameraBefore.lon,
|
|
);
|
|
report(
|
|
r?.ok === true &&
|
|
r?.category === 'news' &&
|
|
r?.stationId &&
|
|
radioCameraDelta < 0.001,
|
|
'behavior: Radio selects Austin news without moving the camera',
|
|
`stationId=${r?.stationId || 'none'} category=${r?.category} cameraΔ=${radioCameraDelta.toFixed(5)}°`,
|
|
);
|
|
r = await run('control_radio', { action: 'volume', volumePct: 30 });
|
|
report(
|
|
r?.ok === true && r?.volumePct === 30,
|
|
'behavior: Radio voice volume reaches the public player',
|
|
`volume=${r?.volumePct}`,
|
|
);
|
|
r = await run('control_radio', { action: 'stop' });
|
|
report(
|
|
r?.ok === true && r?.audioState === 'stopped',
|
|
'behavior: Radio voice stop releases playback',
|
|
`audioState=${r?.audioState}`,
|
|
);
|
|
|
|
// (2) two pins near the camera
|
|
await run('clear_annotations', {});
|
|
await settle(400);
|
|
r = await run('annotate_map', {
|
|
annotations: [
|
|
{
|
|
type: 'pin',
|
|
target: 'Texas State Capitol, Austin',
|
|
label: 'Capitol',
|
|
},
|
|
{ type: 'pin', target: 'Zilker Park, Austin', label: 'Zilker' },
|
|
],
|
|
});
|
|
await settle(2500);
|
|
let annoCount = await page.evaluate(
|
|
() =>
|
|
window.__gevAnnotations?.list?.().length ??
|
|
window.__gevAnnotations?.count?.() ??
|
|
-1,
|
|
);
|
|
report(
|
|
(r?.drawn ?? 0) >= 2 && (annoCount >= 2 || annoCount === -1),
|
|
'behavior: two pins drawn near camera',
|
|
`result.drawn=${r?.drawn} failed=${r?.failed} listCount=${annoCount}`,
|
|
);
|
|
|
|
// (3) walking route between them (street-following path, sane result)
|
|
r = await run('annotate_map', {
|
|
annotations: [
|
|
{
|
|
type: 'route',
|
|
points: [
|
|
{ target: 'Texas State Capitol, Austin' },
|
|
{ target: 'Zilker Park, Austin' },
|
|
],
|
|
mode: 'walking',
|
|
label: 'Capitol → Zilker walk',
|
|
},
|
|
],
|
|
});
|
|
await settle(6000);
|
|
const routeItem =
|
|
(r?.items || []).find((it) => it.type === 'route') || (r?.items || [])[0];
|
|
report(
|
|
(r?.drawn ?? 0) >= 1 && routeItem?.ok !== false,
|
|
'behavior: walking route Capitol→Zilker draws',
|
|
`drawn=${r?.drawn} route=${JSON.stringify(routeItem)?.slice(0, 140)}`,
|
|
);
|
|
|
|
if (DEIXIS_ONLY) {
|
|
await run('set_layer_visibility', { layerId: 'flights', enabled: true });
|
|
await settle(45000);
|
|
await run('frame_overhead', { target: 'flights' });
|
|
await settle(7000);
|
|
await runDeixisBehavior(page, run, settle);
|
|
report(
|
|
overpassRequests.length === 0,
|
|
'behavior: no Overpass request during the run',
|
|
overpassRequests.length
|
|
? overpassRequests.slice(0, 3).join(' | ')
|
|
: 'none',
|
|
);
|
|
return;
|
|
}
|
|
|
|
// (4) frame_overhead frames nearby entities of an ENABLED layer with a
|
|
// cinematic oblique pull-back (NOT nadir — that is its design). Flights
|
|
// render one poll interval behind live, so give the layer time to populate.
|
|
await run('set_layer_visibility', { layerId: 'flights', enabled: true });
|
|
await settle(45000);
|
|
r = await run('frame_overhead', { target: 'flights' });
|
|
await settle(7000);
|
|
if (r?.ok === false && /not enabled/i.test(r?.error || '')) {
|
|
report(
|
|
false,
|
|
'behavior: frame_overhead frames flights',
|
|
`layer enable did not stick: ${r?.error}`,
|
|
);
|
|
} else if (r?.ok && (r.count === 0 || r.framedCount === 0)) {
|
|
skipped(
|
|
'behavior: frame_overhead frames flights',
|
|
'no flights in range right now (empty sky) — framing path OK',
|
|
);
|
|
} else {
|
|
report(
|
|
r?.ok === true,
|
|
'behavior: frame_overhead frames flights',
|
|
`result=${JSON.stringify(r)?.slice(0, 140)}`,
|
|
);
|
|
}
|
|
|
|
// (4-deixis) Point-and-ask with a synthetic pointer on the live canvas.
|
|
await runDeixisBehavior(page, run, settle);
|
|
|
|
// (4a) Deterministic owner-transfer probes use the real product runner and
|
|
// camera policy with synthetic target records only. This proves ordering
|
|
// without claiming that the live AIS stream delivered a vessel.
|
|
const ownerTransfer = await page.evaluate(async () => {
|
|
const app = window.__godsEyeView;
|
|
const runner = window.__gevVoiceCommands?.runner;
|
|
const { viewer, dataManager, styleManager } = app || {};
|
|
const fireEntry = dataManager?.layers?.get('local-firms');
|
|
const vesselEntry = dataManager?.layers?.get('ais-live-vessels');
|
|
const flightsEntry = dataManager?.layers?.get('flights');
|
|
if (!runner || !fireEntry || !vesselEntry || !flightsEntry) {
|
|
return { error: 'required product modules unavailable' };
|
|
}
|
|
|
|
const original = {
|
|
isEnabled: dataManager.isEnabled,
|
|
fireModule: fireEntry.module,
|
|
vesselModule: vesselEntry.module,
|
|
flightsModule: flightsEntry.module,
|
|
flyToBoundingSphere: viewer.camera.flyToBoundingSphere,
|
|
cockpitActive: styleManager.cockpitView?.active,
|
|
};
|
|
const flightStarts = [];
|
|
let currentKind = null;
|
|
let vesselSelections = 0;
|
|
viewer.camera.flyToBoundingSphere = function (...args) {
|
|
flightStarts.push({
|
|
kind: currentKind,
|
|
trackingReleased: !viewer.trackedEntity,
|
|
});
|
|
return original.flyToBoundingSphere.apply(this, args);
|
|
};
|
|
dataManager.isEnabled = function (id) {
|
|
if (['local-firms', 'ais-live-vessels', 'flights'].includes(id))
|
|
return true;
|
|
return original.isEnabled.call(this, id);
|
|
};
|
|
fireEntry.module = {
|
|
...original.fireModule,
|
|
getStrongestFire: () => ({
|
|
id: 'qa-synthetic-fire',
|
|
label: 'QA synthetic fire',
|
|
latitude: 37.7749,
|
|
longitude: -122.4194,
|
|
frp: 922,
|
|
}),
|
|
};
|
|
vesselEntry.module = {
|
|
...original.vesselModule,
|
|
findByQuery: () => ({
|
|
mmsi: '999000111',
|
|
name: 'QA synthetic vessel',
|
|
latitude: 29.7604,
|
|
longitude: -95.3698,
|
|
}),
|
|
selectById: () => {
|
|
vesselSelections += 1;
|
|
return true;
|
|
},
|
|
};
|
|
flightsEntry.module = {
|
|
...original.flightsModule,
|
|
getNearby: () => [
|
|
{
|
|
id: 'qa-aircraft',
|
|
position: viewer.camera.positionWC.clone(),
|
|
},
|
|
],
|
|
};
|
|
|
|
const results = {};
|
|
try {
|
|
for (const [kind, args] of [
|
|
['fire', { query: 'strongest fire', layerId: 'local-firms' }],
|
|
[
|
|
'vessel',
|
|
{ query: 'QA synthetic vessel', layerId: 'ais-live-vessels' },
|
|
],
|
|
]) {
|
|
const sentinel = viewer.entities.add({ id: `qa-prior-${kind}` });
|
|
viewer.trackedEntity = sentinel;
|
|
const generationBefore = styleManager._navigationGeneration;
|
|
currentKind = kind;
|
|
const result = await runner('track_entity', args);
|
|
results[kind] = {
|
|
ok: result?.ok === true,
|
|
generationAdvanced:
|
|
styleManager._navigationGeneration > generationBefore,
|
|
trackingReleased: !viewer.trackedEntity,
|
|
};
|
|
viewer.camera.cancelFlight();
|
|
viewer.entities.remove(sentinel);
|
|
}
|
|
|
|
await runner('move_camera', { motion: 'stop' });
|
|
const seededMotion = await runner('move_camera', {
|
|
motion: 'pan',
|
|
direction: 'right',
|
|
mode: 'continuous',
|
|
});
|
|
const cockpitSentinel = viewer.entities.add({ id: 'qa-cockpit-owner' });
|
|
viewer.trackedEntity = cockpitSentinel;
|
|
const generationBefore = styleManager._navigationGeneration;
|
|
const flightsBefore = flightStarts.length;
|
|
const selectionsBefore = vesselSelections;
|
|
styleManager.cockpitView.active = true;
|
|
const refused = [];
|
|
for (const [name, args] of [
|
|
['move_camera', { motion: 'pan', direction: 'right' }],
|
|
['move_camera', { motion: 'stop' }],
|
|
['fly_route', { speed: 'fast' }],
|
|
['frame_overhead', { target: 'flights' }],
|
|
['track_entity', { query: 'strongest fire', layerId: 'local-firms' }],
|
|
[
|
|
'track_entity',
|
|
{ query: 'QA synthetic vessel', layerId: 'ais-live-vessels' },
|
|
],
|
|
]) {
|
|
refused.push((await runner(name, args))?.ok === false);
|
|
}
|
|
results.cockpit = {
|
|
allRefused: refused.every(Boolean),
|
|
generationUnchanged:
|
|
styleManager._navigationGeneration === generationBefore,
|
|
trackingUnchanged: viewer.trackedEntity === cockpitSentinel,
|
|
noFlight: flightStarts.length === flightsBefore,
|
|
noSelection: vesselSelections === selectionsBefore,
|
|
};
|
|
styleManager.cockpitView.active = false;
|
|
results.cockpit.motionUnchanged =
|
|
seededMotion?.ok === true &&
|
|
(await runner('move_camera', { motion: 'stop' }))?.stopped === true;
|
|
viewer.trackedEntity = undefined;
|
|
viewer.entities.remove(cockpitSentinel);
|
|
} finally {
|
|
styleManager.cockpitView.active = original.cockpitActive;
|
|
viewer.camera.flyToBoundingSphere = original.flyToBoundingSphere;
|
|
dataManager.isEnabled = original.isEnabled;
|
|
fireEntry.module = original.fireModule;
|
|
vesselEntry.module = original.vesselModule;
|
|
flightsEntry.module = original.flightsModule;
|
|
}
|
|
return { results, flightStarts, vesselSelections };
|
|
});
|
|
const takeoversPass =
|
|
['fire', 'vessel'].every((kind) => {
|
|
const result = ownerTransfer?.results?.[kind];
|
|
const flight = ownerTransfer?.flightStarts?.find(
|
|
(candidate) => candidate.kind === kind,
|
|
);
|
|
return (
|
|
result?.ok &&
|
|
result?.generationAdvanced &&
|
|
result?.trackingReleased &&
|
|
flight?.trackingReleased
|
|
);
|
|
}) && ownerTransfer?.vesselSelections === 1;
|
|
report(
|
|
takeoversPass,
|
|
'behavior: synthetic fire/vessel voice targets take camera authority from tracked aircraft',
|
|
`syntheticTarget=true result=${JSON.stringify(ownerTransfer)?.slice(0, 240)}`,
|
|
);
|
|
const cockpit = ownerTransfer?.results?.cockpit;
|
|
report(
|
|
Boolean(
|
|
cockpit?.allRefused &&
|
|
cockpit?.generationUnchanged &&
|
|
cockpit?.trackingUnchanged &&
|
|
cockpit?.noFlight &&
|
|
cockpit?.noSelection &&
|
|
cockpit?.motionUnchanged,
|
|
),
|
|
'behavior: Cockpit refuses every named voice camera route before mutation',
|
|
`result=${JSON.stringify(cockpit)}`,
|
|
);
|
|
|
|
// (4b) analyst engine end-to-end: count flights over Texas (region ring
|
|
// via NE-pack/admin machinery), then a follow-up over the same set.
|
|
// A cold boundary lookup can exceed the resolver's budget; it answers
|
|
// region-timeout and keeps loading, so asking again must succeed.
|
|
let texasTimeouts = 0;
|
|
r = await run('analyst_query', {
|
|
layers: ['flights'],
|
|
scope: { kind: 'region', name: 'Texas' },
|
|
limit: 5,
|
|
});
|
|
while (r?.code === 'region-timeout' && texasTimeouts < 8) {
|
|
texasTimeouts += 1;
|
|
await settle(5000);
|
|
r = await run('analyst_query', {
|
|
layers: ['flights'],
|
|
scope: { kind: 'region', name: 'Texas' },
|
|
limit: 5,
|
|
});
|
|
}
|
|
report(
|
|
r?.ok === true &&
|
|
Number.isFinite(r?.count) &&
|
|
r.count > 0 &&
|
|
String(r?.coverage?.scope || '').includes('Texas'),
|
|
'behavior: analyst counts flights over Texas',
|
|
`count=${r?.count} scope=${r?.coverage?.scope} timeouts=${texasTimeouts} err=${r?.error || ''}`,
|
|
);
|
|
r = await run('analyst_query', {
|
|
followUp: true,
|
|
filters: [{ field: 'onGround', op: 'eq', value: false }],
|
|
sortBy: 'altitudeM',
|
|
limit: 3,
|
|
});
|
|
report(
|
|
r?.ok === true && r?.coverage?.followUp === true,
|
|
'behavior: analyst follow-up re-filters the remembered set',
|
|
`count=${r?.count} followUp=${r?.coverage?.followUp}`,
|
|
);
|
|
// Require the bundled boundary source and the existing whole-query limit.
|
|
// This elapsed time includes record acquisition and filtering; the region
|
|
// resolver has its own separate fallback budget.
|
|
const gulfStarted = Date.now();
|
|
r = await run('analyst_query', {
|
|
layers: ['flights'],
|
|
scope: { kind: 'region', name: 'Gulf of Mexico' },
|
|
limit: 5,
|
|
});
|
|
const gulfMs = Date.now() - gulfStarted;
|
|
report(
|
|
r?.ok === true &&
|
|
String(r?.coverage?.scope || '').includes('Gulf of Mexico') &&
|
|
r?.coverage?.scopeSource === 'natural-earth' &&
|
|
gulfMs < 3000,
|
|
'behavior: analyst resolves the Gulf of Mexico from the bundled pack',
|
|
`scope=${r?.coverage?.scope} source=${r?.coverage?.scopeSource} ms=${gulfMs} err=${r?.error || ''}`,
|
|
);
|
|
|
|
// (5) zoom_to_globe is ABSOLUTE full-earth (>12,000 km band)
|
|
r = await run('zoom_to_globe', {});
|
|
await page
|
|
.waitForFunction(
|
|
() =>
|
|
window.__godsEyeView.viewer.camera.positionCartographic.height /
|
|
1000 >
|
|
12000,
|
|
{ timeout: 30_000, polling: 250 },
|
|
)
|
|
.catch(() => {});
|
|
cam = await camState();
|
|
report(
|
|
cam.altKm > 12000,
|
|
'behavior: zoom_to_globe reaches global band',
|
|
`alt=${Math.round(cam.altKm)}km (want >12000)`,
|
|
);
|
|
|
|
// (6) natural-region swath: overview of the Alps must CAP the range
|
|
r = await run('fly_to_location', {
|
|
query: 'the Alps',
|
|
viewMode: 'overview',
|
|
});
|
|
await page
|
|
.waitForFunction(
|
|
() =>
|
|
window.__godsEyeView.viewer.camera.positionCartographic.height /
|
|
1000 <
|
|
900,
|
|
{ timeout: 40_000, polling: 250 },
|
|
)
|
|
.catch(() => {});
|
|
cam = await camState();
|
|
const swathMode = r?.navigationMode || r?.mode || '(none)';
|
|
report(
|
|
String(swathMode).includes('swath') || cam.altKm < 900,
|
|
'behavior: Alps overview uses capped swath, not whole-bbox space view',
|
|
`navigationMode=${swathMode} alt=${Math.round(cam.altKm)}km (want swath / <900km)`,
|
|
);
|
|
|
|
// (6b) THE owner field finding: "outline the Alps" must draw the real
|
|
// range ring (Natural Earth first-rung, offline → resolves in seconds),
|
|
// not a 60 km² meadow and not a stuck point. Camera is over the Alps
|
|
// from (6), so the proximity gate and the containment guard both pass.
|
|
r = await run('annotate_map', {
|
|
annotations: [{ type: 'area', target: 'the Alps', label: 'The Alps' }],
|
|
});
|
|
let alpsOutline = false;
|
|
for (let i = 0; i < 10 && !alpsOutline; i += 1) {
|
|
await settle(1500);
|
|
alpsOutline = await page.evaluate(() => {
|
|
// list() returns RAW annotation objects: outline presence = ring array
|
|
// (the `outline` boolean exists only in the tool-result mapping).
|
|
const items = window.__gevAnnotations?.list?.() || [];
|
|
const alps = items.find((it) => /alps/i.test(it.label || ''));
|
|
return Array.isArray(alps?.ring) && alps.ring.length >= 8;
|
|
});
|
|
}
|
|
report(
|
|
(r?.drawn ?? 0) >= 1 && alpsOutline,
|
|
'behavior: "outline the Alps" draws the Natural Earth range ring',
|
|
`drawn=${r?.drawn} outlineResolved=${alpsOutline}`,
|
|
);
|
|
|
|
// (6b2) proximity-gate NEGATIVE control at a LOCAL far view (Alps swath,
|
|
// ~165 km): annotating Austin from here must be an honest rejection.
|
|
// NOT run at globe scale — the gate is view-scale-aware by design and a
|
|
// global view legitimately allows marking anywhere.
|
|
r = await run('annotate_map', {
|
|
annotations: [
|
|
{ type: 'pin', target: 'Zilker Park, Austin', label: 'FarPin' },
|
|
],
|
|
});
|
|
await settle(1500);
|
|
report(
|
|
(r?.drawn ?? 1) === 0 || (r?.failed ?? 0) >= 1,
|
|
'behavior: far-target annotate is proximity-rejected by design',
|
|
`drawn=${r?.drawn} failed=${r?.failed}`,
|
|
);
|
|
|
|
// Camera-verb scenarios: shed the heavy layers first — headless
|
|
// SwiftShader drops to ~1 fps with fires+vessels loaded and every
|
|
// motion assert starves (environment, not product).
|
|
await run('set_layer_visibility', {
|
|
layerId: 'local-firms',
|
|
enabled: false,
|
|
});
|
|
await run('set_layer_visibility', {
|
|
layerId: 'ais-live-vessels',
|
|
enabled: false,
|
|
});
|
|
await settle(1500);
|
|
|
|
// (6c) move_camera orbit ONCE: bounded eased ~30° heading advance.
|
|
const heading = () =>
|
|
page.evaluate(
|
|
() => (window.__godsEyeView.viewer.camera.heading * 180) / Math.PI,
|
|
);
|
|
let h0 = await heading();
|
|
r = await run('move_camera', { motion: 'orbit', mode: 'once' });
|
|
await settle(5000);
|
|
let h1 = await heading();
|
|
let dOnce = Math.abs(((h1 - h0 + 540) % 360) - 180);
|
|
report(
|
|
r?.ok === true && dOnce > 10 && dOnce < 45,
|
|
'behavior: orbit once advances ~30° and self-stops',
|
|
`Δheading=${dOnce.toFixed(1)}° result=${JSON.stringify(r)?.slice(0, 90)}`,
|
|
);
|
|
|
|
// (6d) continuous orbit runs until stop; stop reports it was active.
|
|
r = await run('move_camera', {
|
|
motion: 'orbit',
|
|
mode: 'continuous',
|
|
speed: 'normal',
|
|
});
|
|
await settle(2500);
|
|
h0 = await heading();
|
|
// SwiftShader can fall near 1 fps. The motion loop deliberately caps a
|
|
// single-frame time step, so allow enough wall time for >1° of observable
|
|
// travel even at that floor while keeping the same product assertion.
|
|
await settle(3500);
|
|
h1 = await heading();
|
|
const moving = Math.abs(((h1 - h0 + 540) % 360) - 180) > 1;
|
|
let stopRes = await run('move_camera', { motion: 'stop' });
|
|
await settle(600);
|
|
h0 = await heading();
|
|
await settle(1200);
|
|
h1 = await heading();
|
|
const frozen = Math.abs(((h1 - h0 + 540) % 360) - 180) < 0.5;
|
|
report(
|
|
moving && stopRes?.stopped === true && frozen,
|
|
'behavior: continuous orbit moves, stop freezes it',
|
|
`moving=${moving} stopped=${stopRes?.stopped} frozen=${frozen}`,
|
|
);
|
|
|
|
// (6e) a navigation tool reclaims the camera from continuous motion.
|
|
await run('move_camera', {
|
|
motion: 'orbit',
|
|
mode: 'continuous',
|
|
speed: 'slow',
|
|
});
|
|
await settle(800);
|
|
await run('zoom_to_globe', {});
|
|
await settle(1000);
|
|
stopRes = await run('move_camera', { motion: 'stop' });
|
|
report(
|
|
stopRes?.stopped === false,
|
|
'behavior: nav tool interrupts continuous motion',
|
|
`stop-after-nav reports stopped=${stopRes?.stopped} (want false)`,
|
|
);
|
|
|
|
// (6e3) CHAINED fly+orbit ("take me to X and orbit it"): orbit called
|
|
// mid-flight must ARM and start once the camera settles (field finding).
|
|
await run('fly_to_location', { query: 'Texas State Capitol' });
|
|
r = await run('move_camera', {
|
|
motion: 'orbit',
|
|
mode: 'continuous',
|
|
speed: 'normal',
|
|
});
|
|
const armedOk = r?.ok === true; // either armed or started, both fine
|
|
await settle(16000);
|
|
h0 = await heading();
|
|
h1 = h0;
|
|
let chainMoving = false;
|
|
// Multi-stage Cesium flights can settle later under SwiftShader even
|
|
// after reaching the destination. Poll for actual heading motion instead
|
|
// of sampling exactly one frame five seconds after arrival.
|
|
for (let i = 0; i < 20 && !chainMoving; i += 1) {
|
|
await settle(1500);
|
|
h1 = await heading();
|
|
chainMoving = Math.abs(((h1 - h0 + 540) % 360) - 180) > 1.5;
|
|
}
|
|
// ARRIVAL is the assert that catches mid-flight capture: the orbit must
|
|
// engage AT the Capitol, not wherever the flight happened to be.
|
|
const camChain = await camState();
|
|
const dCapitol = Math.hypot(camChain.lat - 30.2747, camChain.lon + 97.7404);
|
|
report(
|
|
armedOk && chainMoving && dCapitol < 0.1 && camChain.altKm < 30,
|
|
'behavior: chained fly+orbit arms and starts AT the destination',
|
|
`Δheading=${Math.abs(((h1 - h0 + 540) % 360) - 180).toFixed(1)}° cam=(${camChain.lat.toFixed(3)},${camChain.lon.toFixed(3)}) alt=${camChain.altKm.toFixed(1)}km dCapitol=${dCapitol.toFixed(3)}°`,
|
|
);
|
|
|
|
// (6e4) zoom during orbit spirals the RADIUS (never a snapped-back no-op).
|
|
// Wait out any residual arrival descent first so altitude deltas are the
|
|
// ZOOM's, not the flight's.
|
|
for (let i = 0; i < 10; i += 1) {
|
|
const a = (await camState()).altKm;
|
|
await settle(1500);
|
|
if (Math.abs((await camState()).altKm - a) < a * 0.02) break;
|
|
}
|
|
let altBefore = (await camState()).altKm;
|
|
r = await run('adjust_camera_zoom', { direction: 'out', amount: 'medium' });
|
|
await settle(2500);
|
|
let altAfter = (await camState()).altKm;
|
|
let stillOrbiting =
|
|
(await run('move_camera', { motion: 'stop' }))?.stopped === true;
|
|
report(
|
|
r?.orbitRadiusAdjusted === true &&
|
|
altAfter > altBefore * 1.1 &&
|
|
stillOrbiting,
|
|
'behavior: zoom during orbit grows the radius while circling',
|
|
`alt ${altBefore.toFixed(2)}→${altAfter.toFixed(2)}km adjusted=${r?.orbitRadiusAdjusted} stillOrbiting=${stillOrbiting}`,
|
|
);
|
|
|
|
// (6e4b) move_camera is explicit navigation: while TRACKING it must first
|
|
// release the follow owner, then start the requested motion.
|
|
await run('set_layer_visibility', { layerId: 'flights', enabled: true });
|
|
// Track a REAL contact the way the voice model does: nearest via analyst.
|
|
const near = await run('analyst_query', {
|
|
layers: ['flights'],
|
|
scope: { kind: 'view' },
|
|
sortBy: 'distance',
|
|
limit: 1,
|
|
});
|
|
const trackId = near?.items?.[0]?.id;
|
|
r = trackId
|
|
? await run('track_entity', { query: trackId, layerId: 'flights' })
|
|
: { ok: false, error: 'no contacts in view' };
|
|
if (r?.ok) {
|
|
const orbitRes = await run('move_camera', {
|
|
motion: 'orbit',
|
|
mode: 'continuous',
|
|
});
|
|
const trackingReleased = await page.evaluate(
|
|
() => !window.__godsEyeView.viewer.trackedEntity,
|
|
);
|
|
const orbitStop = await run('move_camera', { motion: 'stop' });
|
|
report(
|
|
orbitRes?.ok === true &&
|
|
trackingReleased &&
|
|
orbitStop?.stopped === true,
|
|
'behavior: move_camera releases tracking before taking the camera',
|
|
`released=${trackingReleased} stopped=${orbitStop?.stopped} result=${JSON.stringify(orbitRes)?.slice(0, 100)}`,
|
|
);
|
|
} else {
|
|
skipped(
|
|
'behavior: move_camera releases tracking before taking the camera',
|
|
`no trackable flight right now (${JSON.stringify(r)?.slice(0, 80)})`,
|
|
);
|
|
}
|
|
await settle(1500);
|
|
|
|
// (6e4c) explicit navigation while tracking SUPERSEDES the follow camera
|
|
// (field finding: "flew there but still tracking — couldn't do anything").
|
|
const near2 = await run('analyst_query', {
|
|
layers: ['flights'],
|
|
scope: { kind: 'anywhere' },
|
|
sortBy: 'distance',
|
|
limit: 1,
|
|
});
|
|
const trackId2 = near2?.items?.[0]?.id;
|
|
if (
|
|
trackId2 &&
|
|
(await run('track_entity', { query: trackId2, layerId: 'flights' }))?.ok
|
|
) {
|
|
await settle(2000);
|
|
await run('fly_to_location', { query: 'Austin, Texas' });
|
|
await settle(6000);
|
|
const stillTracked = await page.evaluate(() =>
|
|
Boolean(window.__godsEyeView.viewer.trackedEntity),
|
|
);
|
|
const camHere = await camState();
|
|
const nearAustin =
|
|
Math.hypot(camHere.lat - 30.2672, camHere.lon + 97.7431) < 1.5;
|
|
report(
|
|
!stillTracked && nearAustin,
|
|
'behavior: fly-to while tracking stops the tracking and arrives',
|
|
`stillTracked=${stillTracked} cam=(${camHere.lat.toFixed(2)},${camHere.lon.toFixed(2)}) nearAustin=${nearAustin}`,
|
|
);
|
|
} else {
|
|
skipped(
|
|
'behavior: fly-to while tracking stops the tracking and arrives',
|
|
'no trackable contact right now',
|
|
);
|
|
}
|
|
|
|
// (6e5) tilt at the clamp answers honestly instead of silently no-oping.
|
|
await page.evaluate(() => {
|
|
const c = window.__godsEyeView.viewer.camera;
|
|
c.setView({
|
|
orientation: {
|
|
heading: c.heading,
|
|
pitch: (-5.2 * Math.PI) / 180,
|
|
roll: 0,
|
|
},
|
|
});
|
|
});
|
|
r = await run('move_camera', {
|
|
motion: 'tilt',
|
|
direction: 'up',
|
|
mode: 'once',
|
|
});
|
|
report(
|
|
r?.ok === false && /limit/i.test(r?.error || ''),
|
|
'behavior: tilt at the clamp reports the limit honestly',
|
|
`result=${JSON.stringify(r)?.slice(0, 120)}`,
|
|
);
|
|
|
|
// (6f) fly_route dollies along the drawn Capitol→Zilker route.
|
|
r = await run('fly_route', { speed: 'fast' });
|
|
await settle(4000);
|
|
const camNow = await camState();
|
|
const nearRoute = Math.hypot(camNow.lat - 30.27, camNow.lon + 97.755) < 0.2;
|
|
stopRes = await run('move_camera', { motion: 'stop' });
|
|
report(
|
|
r?.ok === true &&
|
|
(r?.distanceM ?? 0) > 2000 &&
|
|
(r?.distanceM ?? 0) < 12000 &&
|
|
nearRoute,
|
|
'behavior: fly_route dollies the drawn route',
|
|
`distanceM=${r?.distanceM} waypoints=${r?.waypoints} cam=(${camNow.lat.toFixed(3)},${camNow.lon.toFixed(3)}) nearRoute=${nearRoute} stopHadMotion=${stopRes?.stopped} err=${r?.error || ''}`,
|
|
);
|
|
|
|
// (7) clear_annotations empties the board
|
|
r = await run('clear_annotations', {});
|
|
await settle(800);
|
|
annoCount = await page.evaluate(
|
|
() =>
|
|
window.__gevAnnotations?.list?.().length ??
|
|
window.__gevAnnotations?.count?.() ??
|
|
-1,
|
|
);
|
|
report(
|
|
r?.ok !== false && (annoCount === 0 || annoCount === -1),
|
|
'behavior: clear_annotations empties board',
|
|
`listCount=${annoCount}`,
|
|
);
|
|
|
|
// (8) get_entity_context returns basemap context
|
|
r = await run('get_entity_context', {});
|
|
const hasContext = !!(
|
|
r &&
|
|
(r.basemap || r.scene || r.context || r.viewScale || r.center)
|
|
);
|
|
report(
|
|
hasContext,
|
|
'behavior: get_entity_context returns scene context',
|
|
`keys=${Object.keys(r || {})
|
|
.slice(0, 8)
|
|
.join(',')}`,
|
|
);
|
|
|
|
// (9) set_context_mode — happy path and a refusal.
|
|
// Contacts is an exclusive mode that tears other layers down, so this runs
|
|
// last and hands the app back to `off` before the screenshot.
|
|
r = await run('set_context_mode', { mode: 'contacts' });
|
|
await settle(2500);
|
|
const contextEntered = await page.evaluate(
|
|
() => window.__godsEyeView?.styleManager?.getContextModeState?.() || null,
|
|
);
|
|
report(
|
|
r?.ok === true && contextEntered?.mode === 'flights',
|
|
'behavior: set_context_mode enters Contacts',
|
|
`result=${JSON.stringify(r)?.slice(0, 140)} state=${JSON.stringify(contextEntered)?.slice(0, 90)}`,
|
|
);
|
|
|
|
// (9a) control_cockpit status reads the real Cockpit surface.
|
|
r = await run('control_cockpit', { action: 'status' });
|
|
report(
|
|
r?.ok === true && r?.state && typeof r.state === 'object',
|
|
'behavior: control_cockpit status reports Cockpit state',
|
|
`result=${JSON.stringify(r)?.slice(0, 160)}`,
|
|
);
|
|
|
|
// (9b) Cockpit entry with nothing to fly is an honest failure, never a
|
|
// silent success that leaves the operator staring at an unchanged map.
|
|
const cockpitBefore = await page.evaluate(() =>
|
|
Boolean(window.__godsEyeView?.styleManager?.cockpitView?.active),
|
|
);
|
|
r = await run('control_cockpit', { action: 'enter' });
|
|
await settle(1200);
|
|
const cockpitAfter = await page.evaluate(() =>
|
|
Boolean(window.__godsEyeView?.styleManager?.cockpitView?.active),
|
|
);
|
|
// Either it genuinely entered (an aircraft was tracked) or it refused with
|
|
// a reason — the one thing it must never do is claim success while inert.
|
|
report(
|
|
(r?.ok === true && cockpitAfter) ||
|
|
(r?.ok !== true && !!r?.error && !cockpitAfter),
|
|
'behavior: control_cockpit entry is honest about whether it entered',
|
|
`ok=${r?.ok} error=${String(r?.error || '').slice(0, 80)} before=${cockpitBefore} after=${cockpitAfter}`,
|
|
);
|
|
if (cockpitAfter) await run('control_cockpit', { action: 'exit' });
|
|
|
|
// (9c) An unknown cockpit action is refused by name.
|
|
r = await run('control_cockpit', { action: 'barrel-roll' });
|
|
report(
|
|
r?.ok === false && /unknown cockpit action/i.test(r?.error || ''),
|
|
'behavior: control_cockpit refuses an unknown action',
|
|
`result=${JSON.stringify(r)?.slice(0, 120)}`,
|
|
);
|
|
|
|
// (9d) An unavailable context mode is refused, and the live mode survives.
|
|
r = await run('set_context_mode', { mode: 'orbital-weather' });
|
|
const contextAfterRefusal = await page.evaluate(
|
|
() => window.__godsEyeView?.styleManager?.getContextModeState?.() || null,
|
|
);
|
|
report(
|
|
r?.ok === false &&
|
|
/unknown context mode/i.test(r?.error || '') &&
|
|
contextAfterRefusal?.mode === 'flights',
|
|
'behavior: set_context_mode refuses an unavailable mode without dropping the live one',
|
|
`result=${JSON.stringify(r)?.slice(0, 120)} state=${JSON.stringify(contextAfterRefusal)?.slice(0, 80)}`,
|
|
);
|
|
|
|
// (9c-2) With Contacts up, an aircraft radius query must carry the panel's
|
|
// own numbers. Field case: analyst said 8 for a 250 km window the panel had
|
|
// at 42 — both honest (analyst counts loaded records, the flights layer
|
|
// reloads by viewport), and the operator saw two answers to one question.
|
|
r = await run('analyst_query', {
|
|
layers: ['flights'],
|
|
scope: { kind: 'radius', km: 250 },
|
|
sortBy: 'distance',
|
|
limit: 3,
|
|
});
|
|
const awarenessFlights = await page.evaluate(() => {
|
|
const snap = window.__godsEyeView?.dataManager?.layers
|
|
?.get('military-awareness')
|
|
?.module?.getContextSnapshot?.();
|
|
const cohort = snap?.cohorts?.find((c) => c.id === 'flights');
|
|
return cohort ? cohort.count : null;
|
|
});
|
|
const windowBlock = r?.contactsWindow || null;
|
|
report(
|
|
Boolean(windowBlock) &&
|
|
windowBlock.flights === awarenessFlights &&
|
|
windowBlock.radiusKm === 250 &&
|
|
typeof windowBlock.centeredOn === 'string' &&
|
|
/loads by viewport/.test(r?.coverage?.note || '') &&
|
|
// Contract rule 3: the count names its own scope.
|
|
/^within 250 km of /.test(r?.scopeLabel || ''),
|
|
'behavior: analyst_query carries the Contacts panel counts and says what it measured',
|
|
`contactsWindow=${JSON.stringify(windowBlock)} awarenessFlights=${awarenessFlights} analystCount=${r?.count} scopeLabel="${r?.scopeLabel}"`,
|
|
);
|
|
|
|
// (9d-2) enter + targetLayer must land on that layer or refuse by name.
|
|
// Field case: enter{targetLayer:"military"} reported ok:true on a FLIGHTS
|
|
// subject, so "cockpit in that military helicopter" put the operator in an
|
|
// airliner.
|
|
if (
|
|
await page.evaluate(() =>
|
|
Boolean(window.__godsEyeView?.styleManager?.cockpitView?.active),
|
|
)
|
|
) {
|
|
await run('control_cockpit', { action: 'exit' });
|
|
await settle(600);
|
|
}
|
|
r = await run('control_cockpit', {
|
|
action: 'enter',
|
|
targetLayer: 'military',
|
|
});
|
|
await settle(1200);
|
|
const layerAfter = r?.state?.subject?.layerId ?? null;
|
|
const cockpitOn = await page.evaluate(() =>
|
|
Boolean(window.__godsEyeView?.styleManager?.cockpitView?.active),
|
|
);
|
|
report(
|
|
(r?.ok === true && layerAfter === 'military' && cockpitOn) ||
|
|
(r?.ok !== true && !!r?.error && !cockpitOn),
|
|
'behavior: control_cockpit enter honours targetLayer or refuses by name',
|
|
`ok=${r?.ok} subjectLayer=${layerAfter} active=${cockpitOn} error=${String(r?.error || '').slice(0, 90)}`,
|
|
);
|
|
if (cockpitOn) {
|
|
await run('control_cockpit', { action: 'exit' });
|
|
await settle(600);
|
|
}
|
|
|
|
// (9d-3) A non-aircraft layer can never be entered — Cockpit flies aircraft.
|
|
r = await run('control_cockpit', {
|
|
action: 'enter',
|
|
targetLayer: 'ais-live-vessels',
|
|
});
|
|
const vesselCockpit = await page.evaluate(() =>
|
|
Boolean(window.__godsEyeView?.styleManager?.cockpitView?.active),
|
|
);
|
|
report(
|
|
r?.ok === false &&
|
|
/aircraft only/i.test(r?.error || '') &&
|
|
!vesselCockpit,
|
|
'behavior: control_cockpit refuses to enter a non-aircraft layer',
|
|
`result=${JSON.stringify(r)?.slice(0, 130)} active=${vesselCockpit}`,
|
|
);
|
|
|
|
// (9e) Exit restores the neutral map.
|
|
r = await run('set_context_mode', { mode: 'off' });
|
|
await settle(2000);
|
|
const contextExited = await page.evaluate(
|
|
() => window.__godsEyeView?.styleManager?.getContextModeState?.() || null,
|
|
);
|
|
report(
|
|
r?.ok !== false && !contextExited?.mode,
|
|
'behavior: set_context_mode exits back to the neutral map',
|
|
`result=${JSON.stringify(r)?.slice(0, 120)} state=${JSON.stringify(contextExited)?.slice(0, 80)}`,
|
|
);
|
|
|
|
// (9f) With Contacts OFF the entry gate is shut. Entry must refuse with the
|
|
// reason rather than produce the half-entered state the operator saw: a
|
|
// plane anchored under the camera with no HUD and no exit control.
|
|
r = await run('control_cockpit', { action: 'status' });
|
|
const gateReport = r?.state || {};
|
|
const cockpitBeforeGate = await page.evaluate(() =>
|
|
Boolean(window.__godsEyeView?.styleManager?.cockpitView?.active),
|
|
);
|
|
report(
|
|
gateReport.active === false &&
|
|
gateReport.entryAllowed === false &&
|
|
gateReport.entryBlockedReason === 'contacts-inactive' &&
|
|
!cockpitBeforeGate,
|
|
'behavior: cockpit status names why entry is blocked with Contacts off',
|
|
`state=${JSON.stringify(gateReport)?.slice(0, 150)}`,
|
|
);
|
|
|
|
// The runner calls controlCockpit directly, so this exercises the app gate
|
|
// rather than the voice tool's own Contacts bootstrap.
|
|
const gated = await page.evaluate(
|
|
() =>
|
|
window.__godsEyeView?.styleManager?.controlCockpit?.('enter') || null,
|
|
);
|
|
const cockpitAfterGate = await page.evaluate(() =>
|
|
Boolean(window.__godsEyeView?.styleManager?.cockpitView?.active),
|
|
);
|
|
report(
|
|
gated?.ok === false &&
|
|
/contacts/i.test(gated?.error || '') &&
|
|
!cockpitAfterGate,
|
|
'behavior: cockpit entry with Contacts off is refused, not half-entered',
|
|
`ok=${gated?.ok} error=${String(gated?.error || '').slice(0, 90)} active=${cockpitAfterGate}`,
|
|
);
|
|
|
|
await runBundledOutlineScenarios({
|
|
page,
|
|
run,
|
|
settle,
|
|
overpassRequests,
|
|
resolverLog,
|
|
packTimes,
|
|
});
|
|
|
|
const shotDir = path.join(ROOT, 'qa-shots');
|
|
fs.mkdirSync(shotDir, { recursive: true });
|
|
await page.screenshot({
|
|
path: path.join(shotDir, 'voice-behavior-final.png'),
|
|
});
|
|
report(
|
|
overpassRequests.length === 0,
|
|
'behavior: no Overpass request during the run',
|
|
overpassRequests.length
|
|
? overpassRequests.slice(0, 3).join(' | ')
|
|
: 'none',
|
|
);
|
|
} catch (e) {
|
|
report(
|
|
false,
|
|
'behavior: layer crashed',
|
|
String(e?.message || e).slice(0, 200),
|
|
);
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Area asks that the bundled packs answer with no boundary lookup: a US
|
|
* Census place, the San Francisco DataSF pack, and Who's On First
|
|
* neighborhoods. Each teleports the camera over the place, draws the area,
|
|
* and checks the outline, the pack that answered, and that no request went
|
|
* to Overpass.
|
|
*/
|
|
const BUNDLED_OUTLINES = [
|
|
{ target: 'Austin', lat: 30.2672, lon: -97.7431, pack: 'us-census' },
|
|
{ target: 'the Mission District', lat: 37.7599, lon: -122.4148, pack: 'datasf', entityKind: 'district' },
|
|
{ target: 'Notting Hill', lat: 51.5118, lon: -0.2046, pack: 'wof' },
|
|
{ target: 'Williamsburg', lat: 40.7081, lon: -73.9571, pack: 'wof' },
|
|
{ target: 'Le Marais', lat: 48.8575, lon: 2.358, pack: 'wof' },
|
|
];
|
|
|
|
async function runBundledOutlineScenarios({
|
|
page,
|
|
run,
|
|
settle,
|
|
overpassRequests,
|
|
resolverLog,
|
|
packTimes,
|
|
}) {
|
|
const timings = [];
|
|
for (const scenario of BUNDLED_OUTLINES) {
|
|
const label = `behavior: "outline ${scenario.target}" draws the bundled ${scenario.pack} outline`;
|
|
await page.evaluate(({ lat, lon }) => {
|
|
const { viewer } = window.__godsEyeView;
|
|
viewer.camera.cancelFlight();
|
|
viewer.camera.setView({
|
|
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
|
longitude: (lon * Math.PI) / 180,
|
|
latitude: (lat * Math.PI) / 180,
|
|
height: 9000,
|
|
}),
|
|
});
|
|
}, scenario);
|
|
await run('clear_annotations', {});
|
|
await settle(1500);
|
|
const overpassBefore = overpassRequests.length;
|
|
const logBefore = resolverLog.length;
|
|
const packsBefore = packTimes.length;
|
|
const started = Date.now();
|
|
const r = await run('annotate_map', {
|
|
annotations: [{
|
|
type: 'area',
|
|
target: scenario.target,
|
|
label: scenario.target,
|
|
...(scenario.entityKind ? { entityKind: scenario.entityKind } : {}),
|
|
}],
|
|
});
|
|
let ringPoints = 0;
|
|
for (let i = 0; i < 40 && ringPoints < 4; i += 1) {
|
|
ringPoints = await page.evaluate((target) => {
|
|
const items = window.__gevAnnotations?.list?.() || [];
|
|
const item = items.find((it) => it.label === target);
|
|
return Array.isArray(item?.ring) ? item.ring.length : 0;
|
|
}, scenario.target);
|
|
if (ringPoints < 4) await settle(250);
|
|
}
|
|
const ms = Date.now() - started;
|
|
const line = resolverLog.slice(logBefore).find((l) => l.includes('bundled')) || '';
|
|
const pack = /\((us-census|datasf|wof|natural-earth)[,)]/.exec(line)?.[1] || 'none';
|
|
const packFiles = packTimes.slice(packsBefore);
|
|
const overpass = overpassRequests.slice(overpassBefore);
|
|
timings.push({ target: scenario.target, ms, pack, overpass: overpass.length });
|
|
report((r?.drawn ?? 0) >= 1 && ringPoints >= 4 && pack === scenario.pack && overpass.length === 0,
|
|
label,
|
|
`drawn=${r?.drawn} ring=${ringPoints} pack=${pack} outlineMs=${ms} overpass=${overpass.length} ` +
|
|
`packFiles=${packFiles.map((f) => `${f.file}:${f.ms}ms`).join(',') || 'cached'}`);
|
|
}
|
|
await run('clear_annotations', {});
|
|
report(
|
|
overpassRequests.length === 0,
|
|
'behavior: no request reached Overpass during the run',
|
|
`overpass=${overpassRequests.length} ${overpassRequests.slice(0, 2).join(' ')}`,
|
|
);
|
|
console.log(` bundled outline timings: ${JSON.stringify(timings)}`);
|
|
console.log(` pack files loaded: ${packTimes.map((f) => `${f.file}:${f.ms}ms`).join(' ')}`);
|
|
}
|
|
|
|
/**
|
|
* Outline acceptance across six countries: each ask teleports the camera
|
|
* over the place, draws the area once cold and once warm (after clearing),
|
|
* and records the rung that answered, the parts drawn, the latency, and how
|
|
* many public Nominatim requests the server spent (its daily count in
|
|
* .gev-cache/nominatim/state.json). Nothing may reach Overpass.
|
|
*/
|
|
const OUTLINE_ASKS = [
|
|
// group, target, camera lat/lon/height(m), extra annotation args, shot name
|
|
['country', 'Switzerland', 46.8, 8.2, 1_200_000],
|
|
['country', 'Iran', 32.4, 53.7, 3_500_000],
|
|
['state', 'Texas', 31.0, -99.0, 2_000_000],
|
|
['state', 'Bavaria', 48.9, 11.4, 900_000],
|
|
['city', 'Austin', 30.2672, -97.7431, 60_000, {}, 'austin-city'],
|
|
['city', 'Paris', 48.8566, 2.3522, 40_000],
|
|
['city', 'Tokyo', 35.6762, 139.6503, 90_000],
|
|
['city', 'São Paulo', -23.5505, -46.6333, 90_000],
|
|
['city', 'Mumbai', 19.076, 72.8777, 60_000],
|
|
['city', 'Lagos', 6.5244, 3.3792, 60_000],
|
|
['neighborhood', 'the Mission District', 37.7599, -122.4148, 8000, { entityKind: 'district' }],
|
|
['neighborhood', 'the Marina', 37.8037, -122.4368, 8000, { entityKind: 'district' }],
|
|
['neighborhood', 'Notting Hill', 51.5118, -0.2046, 8000, { entityKind: 'district' }, 'notting-hill'],
|
|
['neighborhood', 'Williamsburg', 40.7081, -73.9571, 8000, { entityKind: 'district' }],
|
|
['neighborhood', 'SoHo', 40.7233, -74.003, 8000, { entityKind: 'district' }],
|
|
['neighborhood', 'Le Marais', 48.8575, 2.358, 8000, { entityKind: 'district' }],
|
|
['neighborhood', 'Shibuya', 35.6595, 139.7005, 8000, { entityKind: 'district' }],
|
|
['landmark', 'the Texas Capitol grounds', 30.2747, -97.7404, 3000, { entityKind: 'compound' }],
|
|
['landmark', 'Central Park', 40.7829, -73.9654, 6000, {}, 'central-park'],
|
|
['landmark', 'the Eiffel Tower', 48.8584, 2.2945, 3000],
|
|
['landmark', 'Stanford', 37.4275, -122.1697, 8000],
|
|
['landmark', 'Stanford University', 37.4275, -122.1697, 8000],
|
|
['landmark', 'Lady Bird Lake', 30.2638, -97.7526, 6000],
|
|
['street', 'Congress Avenue', 30.2672, -97.7425, 3000, { entityKind: 'street' }, 'congress-avenue'],
|
|
['street', 'Lombard Street', 37.8021, -122.4187, 3000, { entityKind: 'street' }],
|
|
['building', 'this building', 30.27472, -97.74035, 1200,
|
|
{ entityKind: 'building', latitude: 30.27472, longitude: -97.74035 }, 'building'],
|
|
].map(([group, target, lat, lon, height, extra = {}, shot = null]) => ({ group, target, lat, lon, height, extra, shot }));
|
|
|
|
const NOMINATIM_STATE = path.join(ROOT, '.gev-cache', 'nominatim', 'state.json');
|
|
function nominatimCount() {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(NOMINATIM_STATE, 'utf8')).count || 0;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
async function runOutlineAcceptance({
|
|
page,
|
|
run,
|
|
settle,
|
|
overpassRequests,
|
|
}) {
|
|
const trace = [];
|
|
page.on('console', (msg) => {
|
|
const text = msg.text();
|
|
if (/^\[(Outline|Resolver)\]/.test(text)) trace.push(text);
|
|
});
|
|
const nominatimStart = nominatimCount();
|
|
const overpassStart = overpassRequests.length;
|
|
if (SHOT_DIR) fs.mkdirSync(path.resolve(ROOT, SHOT_DIR), { recursive: true });
|
|
|
|
const teleport = (ask) => page.evaluate(({ lat, lon, height }) => {
|
|
const { viewer } = window.__godsEyeView;
|
|
viewer.camera.cancelFlight();
|
|
viewer.camera.setView({
|
|
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
|
longitude: (lon * Math.PI) / 180,
|
|
latitude: (lat * Math.PI) / 180,
|
|
height,
|
|
}),
|
|
});
|
|
}, ask);
|
|
|
|
const rungOf = (lines, state) => {
|
|
for (const line of lines) {
|
|
const m = /→ (openfreemap|nominatim)\b/.exec(line) ||
|
|
/bundled → ([a-z-]+)/.exec(line) ||
|
|
/\((us-census|datasf|wof|natural-earth),/.exec(line);
|
|
if (m) return m[1];
|
|
}
|
|
if (state?.synthesized) return 'approximate';
|
|
return state?.ring >= 4 ? 'other' : 'point';
|
|
};
|
|
|
|
const once = async (ask) => {
|
|
await run('clear_annotations', {});
|
|
await teleport(ask);
|
|
await settle(1200);
|
|
trace.length = 0;
|
|
const before = nominatimCount();
|
|
const label = `QA ${ask.target}`;
|
|
const started = Date.now();
|
|
const r = await run('annotate_map', {
|
|
annotations: [{ type: 'area', target: ask.target, label, ...ask.extra }],
|
|
});
|
|
let state = null;
|
|
for (let i = 0; i < 120; i += 1) {
|
|
state = await page.evaluate((l) => {
|
|
const it = (window.__gevAnnotations?.list?.() || []).find((a) => a.label === l);
|
|
if (!it) return null;
|
|
return {
|
|
ring: Array.isArray(it.ring) ? it.ring.length : 0,
|
|
parts: Array.isArray(it.polygons) && it.polygons.length ? it.polygons.length : (it.ring ? 1 : 0),
|
|
synthesized: Boolean(it.synthesized),
|
|
unavailable: Boolean(it.outlineUnavailable),
|
|
pending: Boolean(it.pendingOutline),
|
|
};
|
|
}, label);
|
|
if (state && !state.pending) break;
|
|
if (!state && r?.drawn === 0 && i > 8) break;
|
|
await settle(250);
|
|
}
|
|
const ms = Date.now() - started;
|
|
return {
|
|
ms,
|
|
drawn: r?.drawn ?? 0,
|
|
state,
|
|
rung: rungOf(trace, state),
|
|
nominatim: nominatimCount() - before,
|
|
error: r?.error || r?.results?.find?.((x) => x?.error)?.error || null,
|
|
};
|
|
};
|
|
|
|
const table = [];
|
|
// --only "Paris|Stanford" reruns a few rows.
|
|
const only = ONLY ? ONLY.split('|') : null;
|
|
for (const ask of OUTLINE_ASKS.filter((a) => !only || only.some((o) => a.target.includes(o)))) {
|
|
const cold = await once(ask);
|
|
if (SHOT_DIR && ask.shot) {
|
|
await settle(HEADFUL ? 6000 : 2500);
|
|
await page.screenshot({ path: path.resolve(ROOT, SHOT_DIR, `${ask.shot}.png`) });
|
|
}
|
|
const warm = await once(ask);
|
|
const rendered = Boolean(cold.state?.ring >= 4) && !cold.state?.synthesized;
|
|
const row = {
|
|
group: ask.group,
|
|
ask: ask.target,
|
|
rung: cold.rung,
|
|
rendered,
|
|
approximate: Boolean(cold.state?.synthesized),
|
|
parts: cold.state?.parts ?? 0,
|
|
coldMs: cold.ms,
|
|
warmMs: warm.ms,
|
|
warmRung: warm.rung,
|
|
nominatim: cold.nominatim + warm.nominatim,
|
|
};
|
|
table.push(row);
|
|
const detail = `rung=${row.rung} parts=${row.parts} cold=${row.coldMs}ms warm=${row.warmMs}ms ` +
|
|
`nominatim=${row.nominatim}${row.approximate ? ' approximate' : ''}` +
|
|
(rendered ? '' : ` trace=${trace.slice(-2).join(' | ').slice(0, 300)}`);
|
|
if (rendered) report(true, `outlines: ${ask.group} "${ask.target}"`, detail);
|
|
else skipped(`outlines: ${ask.group} "${ask.target}" (no exact outline)`, detail);
|
|
}
|
|
await run('clear_annotations', {});
|
|
|
|
const overpass = overpassRequests.slice(overpassStart);
|
|
report(overpass.length === 0, 'outlines: no request reached Overpass during the run',
|
|
`overpass=${overpass.length} ${overpass.slice(0, 2).join(' ')}`);
|
|
const used = nominatimCount() - nominatimStart;
|
|
report(used <= 30, 'outlines: public Nominatim requests within the run budget (30)', `used=${used}`);
|
|
|
|
console.log('\n | Group | Ask | Rung | Outline | Parts | Cold ms | Warm ms | Nominatim |');
|
|
console.log(' |---|---|---|---|---|---|---|---|');
|
|
for (const row of table) {
|
|
const outline = row.rendered ? 'yes' : row.approximate ? 'approximate' : 'no (point)';
|
|
console.log(` | ${row.group} | ${row.ask} | ${row.rung} | ${outline} | ${row.parts} | ${row.coldMs} | ${row.warmMs} | ${row.nominatim} |`);
|
|
}
|
|
if (SHOT_DIR)
|
|
fs.writeFileSync(path.resolve(ROOT, SHOT_DIR, 'outline-table.json'), JSON.stringify(table, null, 2));
|
|
}
|
|
|
|
// ── Main ────────────────────────────────────────────────────
|
|
console.log('\nVoice-surface QA harness');
|
|
console.log(` App URL : ${APP_URL}`);
|
|
console.log(` Layer : ${LAYER}\n`);
|
|
|
|
if (LAYER === 'behavior' || LAYER === 'all') await runBehaviorLayer();
|
|
if (LAYER === 'routing' || LAYER === 'all') await runRoutingLayer();
|
|
if (LAYER === 'deixis') await runDeixisRoutingLayer();
|
|
if (LAYER === 'deixis-live') await runDeixisLiveLayer();
|
|
|
|
console.log('\n────────────────────────────────────────────────────────────');
|
|
console.log(` RESULT: ${pass} passed, ${fail} failed, ${skip} skipped`);
|
|
if (failures.length) failures.forEach((f) => console.log(` ✗ ${f.label}`));
|
|
console.log('────────────────────────────────────────────────────────────\n');
|
|
process.exitCode = fail > 0 ? 1 : 0;
|