@@ -2,6 +2,8 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { VoiceStateMachine } from '../daemon/voice-state.js';
|
||||
import { QvacScheduler } from '../daemon/qvac-scheduler.js';
|
||||
import { JarvisDaemon } from '../daemon/index.js';
|
||||
import { spokenReply } from '../skills/voice-prompt.js';
|
||||
|
||||
test('voice state machine handles wake, reply, cancel, and idle sleep', () => {
|
||||
let now = 0;
|
||||
@@ -20,6 +22,37 @@ test('typed questions wake an armed voice session before thinking', () => {
|
||||
assert.equal(voice.state, 'THINKING');
|
||||
});
|
||||
|
||||
test('typed questions barge in from SPEAKING', () => {
|
||||
const voice = new VoiceStateMachine();
|
||||
voice.wake(); voice.utterance(); voice.speak();
|
||||
voice.typedUtterance();
|
||||
assert.equal(voice.state, 'THINKING');
|
||||
});
|
||||
|
||||
test('spoken replies use harness text and strip HUD sidecars', () => {
|
||||
assert.equal(spokenReply({ ok: true, text: 'Hello there.', reason: 'stop' }), 'Hello there.');
|
||||
assert.equal(spokenReply({ text: 'Spoken. <jarvis_hud>{"title":"x","chips":[]}</jarvis_hud>' }), 'Spoken.');
|
||||
assert.equal(spokenReply({}), '');
|
||||
assert.equal(spokenReply('[object Object]'), '');
|
||||
});
|
||||
|
||||
test('ask extracts harness reply text instead of stringifying the object', async () => {
|
||||
const daemon = new JarvisDaemon();
|
||||
daemon.harness = { ask: async () => ({ ok: true, text: 'Hello there.', reason: 'stop' }), cancel() {}, close: async () => {} };
|
||||
daemon.voiceLoop = null;
|
||||
const replies = [];
|
||||
daemon.on('Reply', (text) => replies.push(text));
|
||||
try {
|
||||
const result = await daemon.ask('Hi');
|
||||
assert.equal(result, 'Hello there.');
|
||||
assert.deepEqual(replies, ['Hello there.']);
|
||||
assert.equal(daemon.lastReply, 'Hello there.');
|
||||
assert.equal(daemon.state, 'LISTENING');
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('QVAC scheduler prioritizes voice and keeps one active job', async () => {
|
||||
const scheduler = new QvacScheduler();
|
||||
const order = [];
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const source = readFileSync(new URL('../apps/gnome-extension/[email protected]/extension.js', import.meta.url), 'utf8');
|
||||
function harness() {
|
||||
const timers = new Map();
|
||||
class Actor {
|
||||
constructor(props = {}) {
|
||||
if ('hexpand' in props) throw new Error('No property hexpand on StWidget');
|
||||
Object.assign(this, props);
|
||||
this.children = [];
|
||||
this.visible = props.visible !== false;
|
||||
this.clutter_text = { connect() {}, ellipsize: null };
|
||||
}
|
||||
add_child(child) { this.children.push(child); }
|
||||
destroy_all_children() { this.children = []; }
|
||||
get_last_child() { return this.children[this.children.length - 1] || null; }
|
||||
connect() { return 1; }
|
||||
hide() { this.visible = false; }
|
||||
show() { this.visible = true; }
|
||||
destroy() { this.destroyed = true; }
|
||||
set_position() {}
|
||||
grab_key_focus() {}
|
||||
set_style() {}
|
||||
add_style_class_name() {}
|
||||
}
|
||||
const context = vm.createContext({
|
||||
Extension: class {},
|
||||
St: { BoxLayout: Actor, Label: Actor, Widget: Actor, Button: Actor, Entry: Actor, ScrollView: Actor, PolicyType: { NEVER: 0, AUTOMATIC: 1 } },
|
||||
Clutter: { ActorAlign: { CENTER: 0, START: 1 }, EVENT_STOP: 1, EVENT_PROPAGATE: 0 },
|
||||
Pango: { EllipsizeMode: { NONE: 0, END: 3 } },
|
||||
Main: { layoutManager: { addChrome() {} } },
|
||||
GLib: {
|
||||
PRIORITY_DEFAULT: 0, SOURCE_REMOVE: false,
|
||||
timeout_add(_priority, _delay, callback) { const id = timers.size + 1; timers.set(id, callback); return id; },
|
||||
Source: { remove(id) { timers.delete(id); } },
|
||||
},
|
||||
log() {},
|
||||
});
|
||||
vm.runInContext(source.replace(/^import .*;\n/gm, '').replace('export default class', 'class') +
|
||||
'\nglobalThis.classes = { ArcOverlay, JarvisExtension, JarvisProxy };', context);
|
||||
return { ...context.classes, timers };
|
||||
}
|
||||
|
||||
test('overlay constructs and destroys with pending halo animation', () => {
|
||||
const { ArcOverlay, timers } = harness();
|
||||
const overlay = new ArcOverlay();
|
||||
assert.equal(overlay.header.children[1].x_expand, true);
|
||||
overlay.attach();
|
||||
overlay.showHalo();
|
||||
overlay.showHalo();
|
||||
assert.equal(timers.size, 1);
|
||||
overlay.destroy();
|
||||
assert.equal(timers.size, 0);
|
||||
});
|
||||
|
||||
test('empty chrome widgets start hidden', () => {
|
||||
const { ArcOverlay } = harness();
|
||||
const overlay = new ArcOverlay();
|
||||
assert.equal(overlay.job.visible, false);
|
||||
assert.equal(overlay.target.visible, false);
|
||||
assert.equal(overlay.cursor.visible, false);
|
||||
assert.equal(overlay.wave.visible, false);
|
||||
});
|
||||
|
||||
test('Reply finalizes a streaming row instead of duplicating [object Object]', () => {
|
||||
const { ArcOverlay } = harness();
|
||||
const overlay = new ArcOverlay();
|
||||
overlay.token('Hello! I am ready.');
|
||||
overlay.finalizeReply('[object Object]');
|
||||
overlay.finalizeReply({ text: 'ignored duplicate' });
|
||||
assert.equal(overlay.transcript.children.length, 1);
|
||||
assert.match(overlay.transcript.children[0].text, /Hello! I am ready/);
|
||||
assert.doesNotMatch(overlay.transcript.children[0].text, /object Object/);
|
||||
});
|
||||
|
||||
test('addRow and token coerce objects to readable text', () => {
|
||||
const { ArcOverlay } = harness();
|
||||
const overlay = new ArcOverlay();
|
||||
overlay.addRow('U', { text: 'Hi there' });
|
||||
overlay.token({ text: 'Hello from Jarvis' });
|
||||
assert.equal(overlay.transcript.children.length, 2);
|
||||
assert.match(overlay.transcript.children[0].text, /Hi there/);
|
||||
assert.match(overlay.transcript.children[1].text, /Hello from Jarvis/);
|
||||
assert.doesNotMatch(overlay.transcript.children.map((row) => row.text).join('\n'), /object Object/);
|
||||
});
|
||||
|
||||
for (const fails of [false, true]) {
|
||||
test('daemon connection finishing after disable is ignored: ' + (fails ? 'failure' : 'success'), async () => {
|
||||
const { JarvisExtension } = harness();
|
||||
const extension = new JarvisExtension();
|
||||
let finish;
|
||||
extension.proxy = { connect: () => new Promise((resolve, reject) => { finish = fails ? reject : resolve; }) };
|
||||
const pending = extension._connectDaemon();
|
||||
extension.proxy = null;
|
||||
extension.overlay = null;
|
||||
finish(fails ? new Error('Disconnected') : undefined);
|
||||
await pending;
|
||||
});
|
||||
}
|
||||
|
||||
test('GNOME GI imports expose default namespaces and panel has a real menu', () => {
|
||||
assert.match(source, /import Shell from 'gi:\/\/Shell'/);
|
||||
assert.match(source, /import Meta from 'gi:\/\/Meta'/);
|
||||
assert.match(source, /import Pango from 'gi:\/\/Pango'/);
|
||||
assert.match(source, /new PanelMenu.Button\(0.0, 'Jarvis QVAC', false\)/);
|
||||
assert.match(source, /PushToTalk/);
|
||||
assert.match(source, /finalizeReply/);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { VoiceLoop, fastCommand } from '../daemon/voice-loop.js';
|
||||
import { pcmS16le } from '../daemon/voice-adapters.js';
|
||||
import { WakeEngine } from '../daemon/wake-engine.js';
|
||||
import { VadSegmenter } from '../daemon/vad.js';
|
||||
import { PipeWireCapture, pcmRms } from '../daemon/audio-pipewire.js';
|
||||
@@ -53,3 +54,29 @@ test('Phase 4 PipeWire capture uses a named 16 kHz mono node', () => {
|
||||
assert.deepEqual(args, ['--record', '--raw', '--format', 's16', '--rate', '16000', '--channels', '1', '--name', 'Jarvis']);
|
||||
capture.stop();
|
||||
});
|
||||
|
||||
test('empty TTS still leaves the daemon listening', async () => {
|
||||
const daemon = new EventEmitter();
|
||||
daemon.state = 'SPEAKING';
|
||||
daemon.voice = { finishSpeaking() { daemon.finished = true; } };
|
||||
daemon.setState = (state) => { daemon.state = state; };
|
||||
const capture = new EventEmitter(); capture.start = () => {}; capture.stop = () => {};
|
||||
const wake = new WakeEngine({ detect: () => null });
|
||||
const loop = new VoiceLoop({ daemon, capture, wake, vad: new VadSegmenter(), tts: { speak: async () => ({ samples: new Int16Array(0) }) } });
|
||||
await loop.speak('[object Object]');
|
||||
assert.equal(daemon.state, 'LISTENING');
|
||||
assert.equal(daemon.finished, true);
|
||||
});
|
||||
|
||||
test('PCM packing uses even s16le sample pairs', () => {
|
||||
const buf = Buffer.alloc(4);
|
||||
buf.writeInt16LE(256, 0);
|
||||
buf.writeInt16LE(-2, 2);
|
||||
const packed = pcmS16le(buf);
|
||||
assert.equal(packed.samples.length, 2);
|
||||
assert.equal(packed.samples[0], 256);
|
||||
assert.equal(packed.samples[1], -2);
|
||||
const fromBytes = pcmS16le(Uint8Array.from([0, 1, 0xfe, 0xff]));
|
||||
assert.equal(fromBytes.samples.length, 2);
|
||||
assert.equal(fromBytes.samples[0], 256);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user