mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
365 lines
11 KiB
JavaScript
365 lines
11 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { createVoiceSession } from '../voice/session.js';
|
|
import { createGevActionRunner } from '../voice/gevActions.js';
|
|
import { createGeminiSession } from '../voice/geminiSession.js';
|
|
import { createNarrationScheduler } from '../voice/narration.js';
|
|
|
|
export const turnOfEventLoop = () =>
|
|
new Promise((resolve) => setImmediate(resolve));
|
|
|
|
function deferred() {
|
|
let resolve;
|
|
const promise = new Promise((done) => {
|
|
resolve = done;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
function ownedClock() {
|
|
let now = 0;
|
|
let nextId = 0;
|
|
const timers = new Map();
|
|
return {
|
|
timers,
|
|
now: () => now,
|
|
setTimer(fn, delay = 0) {
|
|
const id = ++nextId;
|
|
timers.set(id, { fn, at: now + Math.max(0, delay) });
|
|
return id;
|
|
},
|
|
clearTimer: (id) => timers.delete(id),
|
|
advance(ms) {
|
|
const until = now + ms;
|
|
let callbacks = 0;
|
|
for (;;) {
|
|
const due = [...timers]
|
|
.filter(([, timer]) => timer.at <= until)
|
|
.sort((a, b) => a[1].at - b[1].at || a[0] - b[0])[0];
|
|
if (!due) break;
|
|
assert.ok(
|
|
++callbacks <= 1000,
|
|
'fixture timer queue must remain bounded',
|
|
);
|
|
timers.delete(due[0]);
|
|
now = due[1].at;
|
|
due[1].fn();
|
|
}
|
|
now = until;
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Real common session -> real Gemini adapter -> real action runner.
|
|
* Only devices, transport, passive UI and wall-clock facilities are fakes.
|
|
* The optional result gate delays return AFTER the actual action commits.
|
|
*/
|
|
export function nativeIntegrationFixture(t) {
|
|
const clock = ownedClock();
|
|
const events = [];
|
|
const sockets = [];
|
|
const tracks = [];
|
|
const audios = [];
|
|
const requests = [];
|
|
const runnerCalls = [];
|
|
const runnerJobs = [];
|
|
const resultGates = new Map();
|
|
const acquisitions = { microphone: 0, audio: 0, token: 0, socket: 0 };
|
|
const state = { height: 1000, renders: 0, zooms: [], earconStarts: 0 };
|
|
const position = { x: 6379137, y: 0, z: 0 };
|
|
const viewer = {
|
|
clock: { onTick: { addEventListener: () => () => {} } },
|
|
camera: {
|
|
positionWC: position,
|
|
positionCartographic: { height: state.height },
|
|
moveEnd: { addEventListener() {} },
|
|
cancelFlight() {},
|
|
zoomIn(metres) {
|
|
position.x -= metres;
|
|
state.height -= metres;
|
|
this.positionCartographic.height = state.height;
|
|
state.zooms.push({ direction: 'in', metres });
|
|
},
|
|
zoomOut(metres) {
|
|
position.x += metres;
|
|
state.height += metres;
|
|
this.positionCartographic.height = state.height;
|
|
state.zooms.push({ direction: 'out', metres });
|
|
},
|
|
},
|
|
scene: {
|
|
canvas: {
|
|
clientWidth: 800,
|
|
clientHeight: 600,
|
|
addEventListener() {},
|
|
removeEventListener() {},
|
|
},
|
|
requestRender() {
|
|
state.renders++;
|
|
},
|
|
},
|
|
};
|
|
const dataManager = { layers: new Map() };
|
|
const actualRunner = createGevActionRunner({
|
|
viewer,
|
|
dataManager,
|
|
styleManager: {},
|
|
});
|
|
const runner = (name, args, options) => {
|
|
runnerCalls.push({ name, args, options });
|
|
const job = (async () => {
|
|
// No fake action result: the camera and envelope come from production.
|
|
const result = await actualRunner(name, args, options);
|
|
const gate = resultGates.get(options.callId);
|
|
if (gate) {
|
|
gate.entered.resolve(result);
|
|
await gate.release.promise;
|
|
}
|
|
return result;
|
|
})();
|
|
runnerJobs.push(job);
|
|
return job;
|
|
};
|
|
class Socket {
|
|
constructor(url) {
|
|
acquisitions.socket++;
|
|
this.url = url;
|
|
this.readyState = 0;
|
|
this.bufferedAmount = 0;
|
|
this.sent = [];
|
|
sockets.push(this);
|
|
queueMicrotask(() => {
|
|
if (this.closed) return;
|
|
this.readyState = 1;
|
|
this.onopen?.();
|
|
});
|
|
}
|
|
send(raw) {
|
|
assert.equal(
|
|
this.readyState,
|
|
1,
|
|
'sending requires the owned open socket',
|
|
);
|
|
const message = JSON.parse(raw);
|
|
this.sent.push(message);
|
|
if (message.setup)
|
|
queueMicrotask(() => this.message({ setupComplete: {} }));
|
|
}
|
|
message(message) {
|
|
this.onmessage?.({ data: JSON.stringify(message) });
|
|
}
|
|
close() {
|
|
this.closed = true;
|
|
this.readyState = 3;
|
|
}
|
|
}
|
|
const session = createVoiceSession({
|
|
runner,
|
|
createAdapter: (hooks) =>
|
|
createGeminiSession({
|
|
...hooks,
|
|
ui: { root: { remove() {} } },
|
|
dataManager,
|
|
now: clock.now,
|
|
setTimer: clock.setTimer,
|
|
clearTimer: clock.clearTimer,
|
|
WebSocketClass: Socket,
|
|
getUserMedia: async () => {
|
|
acquisitions.microphone++;
|
|
const track = {
|
|
enabled: true,
|
|
readyState: 'live',
|
|
stopCalls: 0,
|
|
stop() {
|
|
this.stopCalls++;
|
|
this.readyState = 'ended';
|
|
},
|
|
};
|
|
tracks.push(track);
|
|
return { getTracks: () => [track], getAudioTracks: () => [track] };
|
|
},
|
|
fetchImpl: async (url, init) => {
|
|
acquisitions.token++;
|
|
requests.push({ url, init });
|
|
const manual = JSON.parse(init.body).inputMode === 'push-to-talk';
|
|
return Response.json({
|
|
token: 'auth_tokens/fixture-not-a-credential',
|
|
model: 'gemini-3.8-live',
|
|
websocketUrl:
|
|
'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContentConstrained',
|
|
config: {
|
|
generationConfig: { responseModalities: ['AUDIO'] },
|
|
...(manual
|
|
? {
|
|
realtimeInputConfig: {
|
|
automaticActivityDetection: { disabled: true },
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
});
|
|
},
|
|
createInput: ({ readStream, operations }) => ({
|
|
...operations,
|
|
setMicrophoneEnabled(enabled) {
|
|
for (const track of readStream()?.getAudioTracks() || [])
|
|
track.enabled = enabled;
|
|
},
|
|
updateVoiceButtonLabel() {},
|
|
setVoiceSpeaker() {},
|
|
startVoiceVisualizer() {},
|
|
startAssistantVoiceVisualizer() {},
|
|
cancelPushToTalkHold() {},
|
|
stopVoiceVisualizer() {},
|
|
detachBindings() {},
|
|
bindPushToTalkShortcut() {},
|
|
resetSession() {
|
|
this.pushToTalkMode =
|
|
this.pushToTalkKeyHeld =
|
|
this.spaceKeyHeld =
|
|
false;
|
|
},
|
|
}),
|
|
createAudio: (callbacks) => {
|
|
acquisitions.audio++;
|
|
const audio = {
|
|
pending: false,
|
|
closed: false,
|
|
outputStream: {},
|
|
async initialize() {},
|
|
connectMicrophone(stream, enabled, metadata) {
|
|
this.stream = stream;
|
|
this.setMicrophoneEnabled(enabled, metadata);
|
|
},
|
|
setMicrophoneEnabled(enabled, metadata) {
|
|
this.enabled = enabled;
|
|
this.metadata = metadata;
|
|
},
|
|
play() {
|
|
this.pending = true;
|
|
},
|
|
clearPlayback() {
|
|
this.pending = false;
|
|
},
|
|
drain() {
|
|
this.pending = false;
|
|
callbacks.onDrain();
|
|
},
|
|
diagnostics: () => ({ outputChunks: 0 }),
|
|
async close() {
|
|
this.closed = true;
|
|
this.pending = false;
|
|
return true;
|
|
},
|
|
};
|
|
audios.push(audio);
|
|
return audio;
|
|
},
|
|
createNarration: (options) =>
|
|
createNarrationScheduler({
|
|
...options,
|
|
now: clock.now,
|
|
setTimer: clock.setTimer,
|
|
clearTimer: clock.clearTimer,
|
|
}),
|
|
createProgressEarcon: () => ({
|
|
available: () => true,
|
|
start() {
|
|
state.earconStarts++;
|
|
},
|
|
stop() {},
|
|
dispose() {},
|
|
}),
|
|
captureViewport: async () => null,
|
|
}),
|
|
});
|
|
session.subscribe((event) => events.push(event));
|
|
const controller = session.adapter.controller;
|
|
t.after(async () => {
|
|
session.destroy();
|
|
for (const gate of resultGates.values()) gate.release.resolve();
|
|
await Promise.allSettled(runnerJobs);
|
|
actualRunner.dispose();
|
|
assert.equal(clock.timers.size, 0, 'fixture must leave no owned timer');
|
|
assert.ok(audios.every((audio) => audio.closed));
|
|
assert.ok(tracks.every((track) => track.readyState === 'ended'));
|
|
assert.ok(sockets.every((socket) => socket.closed));
|
|
});
|
|
return {
|
|
session,
|
|
controller,
|
|
state,
|
|
events,
|
|
sockets,
|
|
tracks,
|
|
audios,
|
|
requests,
|
|
acquisitions,
|
|
runnerCalls,
|
|
clock,
|
|
holdResult(callId) {
|
|
const gate = { entered: deferred(), release: deferred() };
|
|
resultGates.set(callId, gate);
|
|
return { ready: gate.entered.promise, release: gate.release.resolve };
|
|
},
|
|
async start() {
|
|
await session.start();
|
|
assert.equal(session.state, 'listening');
|
|
assert.equal(controller.getDiagnostics().connected, true);
|
|
return sockets.at(-1);
|
|
},
|
|
activity(socket = sockets.at(-1)) {
|
|
socket.message({ voiceActivity: { type: 'ACTIVITY_START' } });
|
|
socket.message({ voiceActivity: { type: 'ACTIVITY_END' } });
|
|
},
|
|
tool(callId, socket = sockets.at(-1)) {
|
|
socket.message({
|
|
toolCall: {
|
|
functionCalls: [
|
|
{
|
|
id: callId,
|
|
name: 'adjust_camera_zoom',
|
|
args: { direction: 'in', amount: 'little' },
|
|
},
|
|
],
|
|
},
|
|
});
|
|
// A deterministic settlement boundary, not a fake action dispatcher.
|
|
return controller.toolQueue;
|
|
},
|
|
outputs(socket = sockets.at(-1)) {
|
|
return socket.sent.flatMap(
|
|
(message) => message.toolResponse?.functionResponses || [],
|
|
);
|
|
},
|
|
complete(socket = sockets.at(-1)) {
|
|
socket.message({ serverContent: { turnComplete: true } });
|
|
},
|
|
};
|
|
}
|
|
|
|
export function assertSuccessfulZoom(h, socket, callId, beforeHeight) {
|
|
assert.equal(h.state.height, beforeHeight * 0.75);
|
|
assert.ok(h.state.renders > 0);
|
|
const calls = h.events.filter(
|
|
(event) => event.type === 'action-call' && event.callId === callId,
|
|
);
|
|
const results = h.events.filter(
|
|
(event) => event.type === 'action-result' && event.callId === callId,
|
|
);
|
|
assert.equal(calls.length, 1);
|
|
assert.equal(results.length, 1);
|
|
assert.equal(calls[0].name, 'adjust_camera_zoom');
|
|
assert.deepEqual(calls[0].arguments, { direction: 'in', amount: 'little' });
|
|
assert.equal(results[0].result.ok, true);
|
|
assert.equal(results[0].result.action, 'adjust_camera_zoom');
|
|
assert.equal(results[0].result.beforeHeightM, Math.round(beforeHeight));
|
|
assert.equal(results[0].result.afterHeightM, Math.round(h.state.height));
|
|
const outputs = h.outputs(socket).filter((output) => output.id === callId);
|
|
assert.equal(outputs.length, 1);
|
|
assert.equal(outputs[0].name, 'adjust_camera_zoom');
|
|
assert.equal(outputs[0].response.ok, true);
|
|
assert.equal(outputs[0].response.action, 'adjust_camera_zoom');
|
|
assert.equal(outputs[0].response.afterHeightM, Math.round(h.state.height));
|
|
}
|