Refs #32 The humanizer's raw fetch to frank-zealot omitted chat_template_kwargs.enable_thinking: false, so the Qwen35 hybrid thought instead of answering (6.9 s reasoning_content, empty content - timing out the 6 s abort; or answering "" and muting the unit). Pin the kwarg, treat empty-content-with-reasoning as failure over silence, and stop the menu's test phrase from blocking its handler. Three red-captured regressions; 26/26 green, verified red against HEAD.
473 lines
18 KiB
Bash
Executable File
473 lines
18 KiB
Bash
Executable File
#!/usr/bin/env bats
|
|
# tests/dankvoice.bats — black-box tests for agent/extensions/dankvoice.ts
|
|
#
|
|
# The extension's contract, from outside: it registers a `say` command and an
|
|
# `agent_end` listener; what is observable is (a) the JSON it POSTs to the
|
|
# speech route, (b) the file it hands to the player, and (c) the notifications
|
|
# and select-lists it renders. A fixture HTTP server stands in for frank-tts
|
|
# (it answers the speech route with a fixed WAV, the roster route with a JSON
|
|
# array, and everything else with 404), a stub player stands in for pw-play
|
|
# (it records its full argv and copies the .wav it was given), and a fixture
|
|
# `wpctl` in $WORK/bin stands in for wireplumber's sink enumeration.
|
|
#
|
|
# Driven by env: DV_EXT (extension under test), DV_STUB / DV_STUB_LOG / DV_COPY
|
|
# (player fixture), DV_PWPLAY (stub aliased as pw-play), DV_WPCTL (the sink
|
|
# name the fixture wpctl reports), DV_MODELS (models.yml fixture),
|
|
# DANKVOICE_CONFIG (settings file), DV_PLAYER_BIN (player command override),
|
|
# DV_MODE (the harness mode reported to the extension), DV_REWRITE (fixture
|
|
# behavior for the humanizer route: prose | echo | reasoning | stall, with
|
|
# DV_STALL_MS).
|
|
|
|
setup() {
|
|
REPO="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
|
|
WORK="$(mktemp -d)"
|
|
|
|
export DV_EXT="$REPO/agent/extensions/dankvoice.ts"
|
|
export DV_STUB="$WORK/player.sh"
|
|
export DV_STUB_LOG="$WORK/player.log"
|
|
export DV_COPY="$WORK/played.wav"
|
|
export DV_EMPTY_BIN="$WORK/emptybin"
|
|
mkdir -p "$DV_EMPTY_BIN"
|
|
|
|
cat > "$DV_STUB" <<'SH'
|
|
#!/usr/bin/env bash
|
|
printf '%s\n' "$*" >> "$DV_STUB_LOG"
|
|
if [ -n "${DV_TIMING_LOG:-}" ]; then printf 'START %s\n' "$(date +%s%3N)" >> "$DV_TIMING_LOG"; fi
|
|
for arg in "$@"; do case "$arg" in *.wav) cp "$arg" "$DV_COPY" ;; esac; done
|
|
sleep "${DV_HOLD:-0.1}"
|
|
if [ -n "${DV_TIMING_LOG:-}" ]; then printf 'END %s\n' "$(date +%s%3N)" >> "$DV_TIMING_LOG"; fi
|
|
SH
|
|
chmod +x "$DV_STUB"
|
|
|
|
# the same stub reachable under the canonical player names, plus a fixture
|
|
# wpctl so output-device enumeration is deterministic on any machine
|
|
mkdir -p "$WORK/bin"
|
|
ln -s "$DV_STUB" "$WORK/bin/pw-play"
|
|
export DV_PWPLAY="$WORK/bin/pw-play"
|
|
export DV_WPCTL="alsa_output.usb-KT.0"
|
|
cat > "$WORK/bin/wpctl" <<'WPC'
|
|
#!/usr/bin/env bash
|
|
printf '57\talsa_output.pci-0000_07_00.6.HiFi__Speaker__sink\taudio/sink\t\n116\t%s\taudio/sink\t*\n' "$DV_WPCTL"
|
|
WPC
|
|
chmod +x "$WORK/bin/wpctl"
|
|
|
|
# settings live in an isolated file so menu choices never leak between tests
|
|
export DANKVOICE_CONFIG="$WORK/settings.json"
|
|
|
|
# models.yml fixture: a chat provider first, the speech provider second
|
|
export DV_MODELS="$WORK/models.yml"
|
|
cat > "$DV_MODELS" <<'YML'
|
|
providers:
|
|
"chat (box)":
|
|
baseUrl: http://10.0.0.1:9999/v1
|
|
models:
|
|
- id: chatmodel
|
|
api: openai-completions
|
|
"speech (box)":
|
|
baseUrl: http://127.0.0.1:1/v1
|
|
auth: none
|
|
models:
|
|
- id: speechmodel
|
|
api: openai-speech
|
|
YML
|
|
|
|
# omp config.yml fixture: the @tiny role points at the chat provider above
|
|
export DV_OMPCONFIG="$WORK/ompconfig.yml"
|
|
cat > "$DV_OMPCONFIG" <<'CFY'
|
|
modelRoles:
|
|
tiny: chat (box)/chatmodel:off
|
|
CFY
|
|
|
|
cat > "$WORK/driver.ts" <<'TS'
|
|
// Black-box driver: stand up the frank-tts fixture, load the extension against
|
|
// it, invoke one scenario, print what the endpoint received and what got played.
|
|
import { createServer } from "node:http";
|
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
import { dirname } from "node:path";
|
|
|
|
const scenario = process.argv[2] ?? "speak";
|
|
const AUDIO = Buffer.from([0x52, 0x49, 0x46, 0x46, 8, 0, 0, 0, 0x57, 0x41, 0x56, 0x45, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
|
|
const started = Date.now();
|
|
interface Call {
|
|
url: string | undefined;
|
|
body: string;
|
|
at: number;
|
|
}
|
|
const received: Call[] = [];
|
|
|
|
const server = createServer((req, res) => {
|
|
const chunks: Buffer[] = [];
|
|
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
req.on("end", () => {
|
|
const at = Date.now() - started;
|
|
const body = Buffer.concat(chunks).toString("utf8");
|
|
received.push({ url: req.url, body, at });
|
|
if (req.url?.endsWith("/audio/voices")) {
|
|
res.setHeader("content-type", "application/json");
|
|
res.end(JSON.stringify(["narrator1", "narrator2"]));
|
|
return;
|
|
}
|
|
if (req.url?.endsWith("/audio/speech")) {
|
|
res.setHeader("content-type", "audio/wav");
|
|
res.end(AUDIO);
|
|
return;
|
|
}
|
|
if (process.env.DV_REWRITE && req.url?.endsWith("/chat/completions")) {
|
|
const respond = () => {
|
|
res.setHeader("content-type", "application/json");
|
|
const spoken = process.env.DV_REWRITE === "echo"
|
|
? String(JSON.parse(body).messages?.[1]?.content ?? "")
|
|
: "Spoken, humanized words.";
|
|
const verdict = process.env.DV_REWRITE === "reasoning"
|
|
? { choices: [{ message: { content: "", reasoning_content: "Long internal deliberation that never reaches the reply field." } }] }
|
|
: { choices: [{ message: { content: spoken } }] };
|
|
res.end(JSON.stringify(verdict));
|
|
};
|
|
if (process.env.DV_REWRITE === "stall") setTimeout(respond, Number(process.env.DV_STALL_MS ?? 200));
|
|
else respond();
|
|
return;
|
|
}
|
|
res.statusCode = 404;
|
|
res.end("{}");
|
|
});
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const port = (server.address() as { port: number }).port;
|
|
|
|
if (scenario === "endpoint") {
|
|
process.env.DANKVOICE_MODELS = process.env.DV_MODELS;
|
|
} else {
|
|
process.env.DANKVOICE_URL = `http://127.0.0.1:${port}/v1`;
|
|
}
|
|
process.env.DANKVOICE_OMPCONFIG = process.env.DV_OMPCONFIG;
|
|
if (scenario !== "endpoint") process.env.DANKVOICE_REWRITE_URL = `http://127.0.0.1:${port}/v1`;
|
|
if (scenario === "noplayer") process.env.PATH = process.env.DV_EMPTY_BIN ?? "/nonexistent-bin";
|
|
else process.env.PATH = `${dirname(process.env.DV_STUB ?? ".")}/bin:${process.env.PATH ?? ""}`;
|
|
process.env.DANKVOICE_PLAYER = scenario === "noplayer" ? "" : process.env.DV_PLAYER_BIN ?? process.env.DV_STUB;
|
|
|
|
const extension = await import(process.env.DV_EXT as string);
|
|
const commands = new Map<string, { handler: (args: string, ctx: unknown) => Promise<void> }>();
|
|
const events = new Map<string, (event: unknown, ctx: unknown) => Promise<void> | void>();
|
|
extension.default({
|
|
registerCommand: (name: string, options: { handler: (args: string, ctx: unknown) => Promise<void> }) => commands.set(name, options),
|
|
on: (event: string, handler: (event: unknown, ctx: unknown) => Promise<void> | void) => events.set(event, handler),
|
|
});
|
|
const answers = (process.env.DV_SELECT ?? "").split("|").filter((answer) => answer !== "");
|
|
let answerIndex = 0;
|
|
const ctx = {
|
|
mode: process.env.DV_MODE ?? "tui",
|
|
ui: {
|
|
notify: (message: string, level?: string) => console.log(`NOTIFY ${level ?? "info"} ${message}`),
|
|
setStatus: (key: string, text?: string) => console.log(`STATUS ${key} ${text ?? ""}`),
|
|
select: (title: string, options: string[]) => {
|
|
console.log(`SELECT ${title} ${options.join("|")}`);
|
|
const answer = answerIndex < answers.length ? answers[answerIndex] : undefined;
|
|
answerIndex += 1;
|
|
return Promise.resolve(answer ?? options[0]);
|
|
},
|
|
},
|
|
};
|
|
const say = commands.get("say");
|
|
const agentEnd = events.get("agent_end");
|
|
const answer = (text: string) => ({ messages: [{ role: "assistant", content: [{ type: "text", text }] }] });
|
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 800));
|
|
|
|
if (!say) {
|
|
console.log("COMMANDS none");
|
|
} else {
|
|
console.log(`COMMANDS ${[...commands.keys()].join(",")} EVENTS ${[...events.keys()].join(",")}`);
|
|
switch (scenario) {
|
|
case "speak":
|
|
await say.handler("hello world", ctx);
|
|
break;
|
|
case "voice":
|
|
await say.handler("voice narrator2", ctx);
|
|
await say.handler("good morning", ctx);
|
|
break;
|
|
case "menu":
|
|
await say.handler("menu", ctx);
|
|
break;
|
|
case "empty":
|
|
await say.handler("", ctx);
|
|
break;
|
|
case "devicemenu":
|
|
await say.handler("menu", ctx);
|
|
await say.handler("hello world", ctx);
|
|
break;
|
|
case "auto":
|
|
await say.handler("on", ctx);
|
|
await agentEnd?.(answer("the answer"), ctx);
|
|
await settle();
|
|
await say.handler("off", ctx);
|
|
await agentEnd?.(answer("the second answer"), ctx);
|
|
break;
|
|
case "continuation":
|
|
await say.handler("on", ctx);
|
|
await agentEnd?.({ ...answer("not yet"), willContinue: true }, ctx);
|
|
await settle();
|
|
await agentEnd?.(answer("the settled answer"), ctx);
|
|
break;
|
|
case "printmode":
|
|
await say.handler("on", ctx);
|
|
await agentEnd?.(answer("quiet"), ctx);
|
|
break;
|
|
case "voices":
|
|
await say.handler("voices", ctx);
|
|
break;
|
|
case "endpoint":
|
|
await say.handler("status", ctx);
|
|
break;
|
|
case "markdown":
|
|
await say.handler("## Title\n\nUse `pw-play` here.\n\n```bash\nnpm i x\n```\n\nsee [docs](http://example.test) and **bold**\n- a\n- b", ctx);
|
|
break;
|
|
case "truncate":
|
|
await say.handler("Sentence one is here. ".repeat(40), ctx);
|
|
break;
|
|
case "sentences":
|
|
await say.handler("First stop here. Second one goes!!! Really? Third ... done", ctx);
|
|
break;
|
|
case "decimals":
|
|
await say.handler("Model 3.5 ships today. Version 1,5 too.", ctx);
|
|
break;
|
|
case "rewrite":
|
|
await say.handler("The deploy failed because a secret was missing.", ctx);
|
|
break;
|
|
case "thinking":
|
|
await say.handler("on", ctx);
|
|
await agentEnd?.({ messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "Considering the wiring." }, { type: "text", text: "The answer is nine." }] }] }, ctx);
|
|
break;
|
|
case "testphrase": {
|
|
const startedAt = Date.now();
|
|
await say.handler("menu", ctx);
|
|
console.log(`HANDLER_MS ${Date.now() - startedAt}`);
|
|
break;
|
|
}
|
|
case "narration":
|
|
await say.handler("on", ctx);
|
|
await agentEnd?.({ messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "Here is the result.", narration: true }] }] }, ctx);
|
|
break;
|
|
case "noplayer":
|
|
await say.handler("speak anyway", ctx);
|
|
break;
|
|
}
|
|
await settle();
|
|
}
|
|
|
|
for (const call of received) {
|
|
const parsed: unknown = JSON.parse(call.body === "" ? "{}" : call.body);
|
|
const field = (key: string): string => {
|
|
if (parsed && typeof parsed === "object" && key in parsed) {
|
|
const value = parsed[key];
|
|
if (typeof value === "string") return value;
|
|
}
|
|
return "-";
|
|
};
|
|
const kwargs = parsed && typeof parsed === "object" && "chat_template_kwargs" in parsed
|
|
? (parsed as { chat_template_kwargs?: { enable_thinking?: unknown } }).chat_template_kwargs
|
|
: undefined;
|
|
const think = kwargs && typeof kwargs === "object" && "enable_thinking" in kwargs ? String(kwargs.enable_thinking) : "absent";
|
|
console.log(`CALL ${call.url} model=${field("model")} format=${field("response_format")} voice=${field("voice")} input=${field("input")}${call.url?.endsWith("/chat/completions") ? ` thinkoff=${think}` : ""}`);
|
|
}
|
|
console.log(`CALLS ${received.filter((call) => call.url?.endsWith("/audio/speech")).length}`);
|
|
const log = process.env.DV_STUB_LOG && existsSync(process.env.DV_STUB_LOG) ? readFileSync(process.env.DV_STUB_LOG, "utf8").trim() : "";
|
|
console.log(`PLAYS ${log === "" ? 0 : log.split("\n").length}`);
|
|
if (log !== "") for (const line of log.split("\n")) console.log(`PLAYER ${line}`);
|
|
if (process.env.DV_COPY && existsSync(process.env.DV_COPY)) console.log(`COPIED ${statSync(process.env.DV_COPY).size}`);
|
|
server.close();
|
|
TS
|
|
|
|
export DV_DRIVER="$WORK/driver.ts"
|
|
}
|
|
|
|
teardown() {
|
|
rm -rf "$WORK"
|
|
}
|
|
|
|
say_run() {
|
|
run env DV_SELECT="${2:-}" bun "$DV_DRIVER" "$1"
|
|
}
|
|
|
|
@test "the extension registers the say command and an agent_end listener" {
|
|
say_run speak
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"COMMANDS say EVENTS agent_end"* ]]
|
|
}
|
|
|
|
@test "speaking posts the text to the speech route" {
|
|
say_run speak
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"CALL /v1/audio/speech model=frank-tts format=wav voice=- input=hello world"* ]]
|
|
}
|
|
|
|
@test "one utterance is played once, as exactly the bytes the endpoint sent" {
|
|
say_run speak
|
|
[[ "$output" == *"PLAYS 1"* ]]
|
|
[[ "$output" == *"COPIED 24"* ]]
|
|
}
|
|
|
|
@test "the voice is named in the request only when the user chose one" {
|
|
say_run speak
|
|
[[ "$output" == *"voice=- input=hello world"* ]]
|
|
say_run voice
|
|
[[ "$output" == *"voice=narrator2 input=good morning"* ]]
|
|
[[ "$output" == *"NOTIFY info dankvoice: voice = narrator2"* ]]
|
|
}
|
|
|
|
@test "auto-speak narrates the final answer once and stays silent after it is disarmed" {
|
|
say_run auto
|
|
[[ "$output" == *"PLAYS 1"* ]]
|
|
[[ "$output" == *"input=the answer"* ]]
|
|
[[ "$output" != *"the second answer"* ]]
|
|
[[ "$output" == *"STATUS dankvoice 🔊"* ]]
|
|
}
|
|
|
|
@test "auto-speak stays silent outside the interactive modes" {
|
|
DV_MODE=print say_run printmode
|
|
[[ "$output" == *"PLAYS 0"* ]]
|
|
[[ "$output" != *"input=quiet"* ]]
|
|
}
|
|
|
|
@test "the voice list comes from the endpoint roster route" {
|
|
say_run voices
|
|
[[ "$output" == *"CALL /v1/audio/voices"* ]]
|
|
[[ "$output" == *"dankvoice voices: narrator1, narrator2"* ]]
|
|
}
|
|
|
|
@test "the endpoint is taken from the speech row of the models file, never the chat provider" {
|
|
say_run endpoint
|
|
[[ "$output" == *"http://127.0.0.1:1/v1/audio/speech"* ]]
|
|
[[ "$output" == *"humanize chatmodel"* ]]
|
|
[[ "$output" != *"10.0.0.1"* ]]
|
|
}
|
|
|
|
@test "markdown never reaches the speech engine" {
|
|
say_run markdown
|
|
[[ "$output" == *"input=Title"* ]]
|
|
[[ "$output" == *"input=Use pw-play here."* ]]
|
|
[[ "$output" == *"input=see docs and bold"* ]]
|
|
[[ "$output" != *"npm i x"* ]]
|
|
[[ "$output" != *"example.test"* ]]
|
|
}
|
|
|
|
@test "the spoken budget keeps only the leading whole segments" {
|
|
DANKVOICE_MAX=60 say_run truncate
|
|
[[ "$output" == *"CALLS 1"* ]]
|
|
[[ "$output" == *"PLAYS 1"* ]]
|
|
[[ "$output" == *"input=Sentence one is here."* ]]
|
|
}
|
|
|
|
@test "a long answer is spoken as sequential sentence-sized requests" {
|
|
say_run sentences
|
|
[[ "$output" == *"CALLS 3"* ]]
|
|
[[ "$output" == *"PLAYS 3"* ]]
|
|
[[ "$output" == *"input=First stop here."* ]]
|
|
[[ "$output" == *"input=Second one goes!!! Really?"* ]]
|
|
[[ "$output" == *"input=Third ... done"* ]]
|
|
}
|
|
|
|
@test "decimals and comma-decimals never split a sentence" {
|
|
say_run decimals
|
|
[[ "$output" == *"CALLS 2"* ]]
|
|
[[ "$output" == *"input=Model 3.5 ships today."* ]]
|
|
[[ "$output" == *"input=Version 1,5 too."* ]]
|
|
}
|
|
|
|
@test "an answer is rewritten into spoken prose before synthesis" {
|
|
DV_REWRITE=prose say_run rewrite
|
|
[[ "$output" == *"CALL /v1/chat/completions"* ]]
|
|
[[ "$output" == *"input=Spoken, humanized words."* ]]
|
|
[[ "$output" == *"CALLS 1"* ]]
|
|
}
|
|
|
|
@test "a failed rewrite speaks the mechanical text unchanged" {
|
|
say_run speak
|
|
[[ "$output" == *"input=hello world"* ]]
|
|
}
|
|
|
|
@test "thinking is narrated before the answer as its own segment" {
|
|
DV_REWRITE=echo say_run thinking
|
|
[[ "$output" == *"input=Considering the wiring."* ]]
|
|
[[ "$output" == *"input=The answer is nine."* ]]
|
|
[[ "$output" == *"CALLS 2"* ]]
|
|
local first second
|
|
first=$(grep -n "input=Considering the wiring\." <<<"$output" | head -1 | cut -d: -f1)
|
|
second=$(grep -n "input=The answer is nine\." <<<"$output" | head -1 | cut -d: -f1)
|
|
[ -n "$first" ] && [ -n "$second" ] && [ "$first" -lt "$second" ]
|
|
}
|
|
|
|
@test "thinking narration can be switched off" {
|
|
DANKVOICE_THINKING=0 DV_REWRITE=echo say_run thinking
|
|
[[ "$output" == *"input=The answer is nine."* ]]
|
|
[[ "$output" != *"input=Considering the wiring."* ]]
|
|
[[ "$output" == *"CALLS 1"* ]]
|
|
}
|
|
|
|
@test "narration-flagged thinking is spoken once as the answer" {
|
|
DV_REWRITE=echo say_run narration
|
|
[[ "$output" == *"input=Here is the result."* ]]
|
|
[[ "$output" == *"CALLS 1"* ]]
|
|
}
|
|
|
|
@test "a missing player is reported, not swallowed" {
|
|
say_run noplayer
|
|
[[ "$output" == *"NOTIFY error dankvoice: no audio player found"* ]]
|
|
[[ "$output" == *"PLAYS 0"* ]]
|
|
}
|
|
|
|
@test "a scheduled continuation is never narrated as the final answer" {
|
|
say_run continuation
|
|
[[ "$output" == *"PLAYS 1"* ]]
|
|
[[ "$output" == *"input=the settled answer"* ]]
|
|
[[ "$output" != *"input=not yet"* ]]
|
|
}
|
|
|
|
@test "the menu lists every knob and a plain /say opens it" {
|
|
say_run menu
|
|
[[ "$output" == *"SELECT dankvoice — options Auto-speak: off (toggle)|Voice: server default (change)|Output device: system default (change)|Player: $DV_STUB (change)|Spoken cap: 700 chars (change)|Thinking narration: on (toggle)|Humanize answers: on (tiny) (toggle)|Speak a test phrase"* ]]
|
|
[[ "$output" == *"NOTIFY info dankvoice: auto-speak armed (server default @ http://127.0.0.1:"* ]]
|
|
say_run empty
|
|
[[ "$output" == *"SELECT dankvoice — options"* ]]
|
|
}
|
|
|
|
@test "a voice picked from the menu is spoken by every later request" {
|
|
say_run menu "Voice: server default (change)|narrator2"
|
|
[[ "$output" == *"SELECT dankvoice — voice server default|narrator1|narrator2"* ]]
|
|
[[ "$output" == *"NOTIFY info dankvoice: voice = narrator2"* ]]
|
|
say_run speak
|
|
[[ "$output" == *"voice=narrator2 input=hello world"* ]]
|
|
}
|
|
|
|
@test "a device chosen from the menu becomes the player's output target" {
|
|
DV_PLAYER_BIN="$DV_PWPLAY" say_run menu "Output device: system default (change)|$DV_WPCTL"
|
|
[[ "$output" == *"SELECT dankvoice — output device system default|alsa_output.pci-0000_07_00.6.HiFi__Speaker__sink|$DV_WPCTL"* ]]
|
|
[[ "$output" == *"NOTIFY info dankvoice: output device = $DV_WPCTL"* ]]
|
|
DV_PLAYER_BIN="$DV_PWPLAY" say_run speak
|
|
[[ "$output" == *"PLAYER --target $DV_WPCTL"* ]]
|
|
}
|
|
|
|
@test "the rewrite asks the humanizer with thinking explicitly off" {
|
|
DV_REWRITE=prose say_run rewrite
|
|
[[ "$output" == *"input=- thinkoff=false"* ]]
|
|
}
|
|
|
|
@test "an answer hiding in reasoning_content speaks the mechanical text, not silence" {
|
|
DV_REWRITE=reasoning say_run rewrite
|
|
[[ "$output" == *"input=The deploy failed because a secret was missing."* ]]
|
|
[[ "$output" == *"CALLS 1"* ]]
|
|
[[ "$output" == *"PLAYS 1"* ]]
|
|
}
|
|
|
|
@test "a rewrite slower than a breath but under the timeout still speaks" {
|
|
DV_REWRITE=stall DV_STALL_MS=300 say_run rewrite
|
|
[[ "$output" == *"input=Spoken, humanized words."* ]]
|
|
[[ "$output" == *"CALLS 1"* ]]
|
|
}
|
|
|
|
@test "the menu test phrase speaks in the background instead of blocking the menu" {
|
|
DV_REWRITE=stall DV_STALL_MS=1500 say_run testphrase "Speak a test phrase"
|
|
[[ "$output" == *"NOTIFY info dankvoice: speaking the test phrase"* ]]
|
|
local ms
|
|
ms=$(grep -o 'HANDLER_MS [0-9]*' <<<"$output" | cut -d' ' -f2)
|
|
[ -n "$ms" ] && [ "$ms" -lt 1200 ]
|
|
}
|