mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Test what the application does, and run it on every pull request
570 tests over the standard library's unittest, so there is nothing to install beyond the PyQt6 the application already needs. They reach neither the network, the microphone, nor the real ~/.config/dikte: urllib is faked at one function, the tools are faked at shutil.which, and every test is handed its own config and data directory. What they hold onto is what a change is most likely to move without meaning to. The request each provider is sent, field by field. A settings window that loads a value into a widget and writes it back, which is where a setting added to one half and not the other is silently reset. The dictation chain end to end: what is transcribed, what is pasted, what lands in the history, and what happens to the audio afterwards. A config file written by an older version. A meeting whose two channels heard the same sentence. 59 of them carry @linux_only, because they cover what Dikte is on this desktop rather than what it does: PipeWire, wl-clipboard, ydotool, KDE's shortcut file. The other 511 pass on any platform, and that line is worth holding as the ports arrive. CONTRIBUTING.md says how to run them, what support.py offers, and the three things about this codebase that trip up a new test.
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
name: tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
python: ["3.11", "3.12", "3.13"]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python }}
|
||||||
|
|
||||||
|
# PyQt6 ships Qt itself, but Qt still loads these from the system, even
|
||||||
|
# for the offscreen platform the tests run on. QtNetwork wants the Kerberos
|
||||||
|
# library, and the widgets want fontconfig, whether or not anything is
|
||||||
|
# ever drawn.
|
||||||
|
- name: Install the Qt runtime libraries
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install --no-install-recommends -y \
|
||||||
|
libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0 \
|
||||||
|
libfontconfig1 libfreetype6 libgssapi-krb5-2
|
||||||
|
|
||||||
|
- name: Install PyQt6
|
||||||
|
run: python -m pip install --quiet PyQt6
|
||||||
|
|
||||||
|
# Nothing else is needed: the code is standard library and PyQt6, and the
|
||||||
|
# tests reach neither the network nor a sound device.
|
||||||
|
- name: Run the tests
|
||||||
|
run: python -m unittest discover --verbose
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
## Running the tests
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m unittest discover # all of them, about a second
|
||||||
|
python -m unittest tests.test_api # one file
|
||||||
|
python -m unittest tests.test_api.Transcribe.test_no_key_at_all
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing to install: the tests use the standard library's `unittest`, and the
|
||||||
|
only dependency is the PyQt6 the application already needs. They reach neither
|
||||||
|
the network, the microphone, nor your real `~/.config/dikte`, so they are safe
|
||||||
|
to run anywhere and they run on a machine with no display.
|
||||||
|
|
||||||
|
CI runs the same command on Python 3.11 through 3.13. A pull request that turns
|
||||||
|
it red will not be merged.
|
||||||
|
|
||||||
|
## Writing one
|
||||||
|
|
||||||
|
Put it in `tests/`, named after the module it covers. Inherit from
|
||||||
|
`tests.support.DikteTest` whenever the code under test touches a file, a
|
||||||
|
setting or the interface language: it hands the test its own config and data
|
||||||
|
directories, resets the language, and puts them back afterwards.
|
||||||
|
|
||||||
|
`tests/support.py` has the rest of what you need:
|
||||||
|
|
||||||
|
| For | Use |
|
||||||
|
| --- | --- |
|
||||||
|
| An HTTP call | `fake_urlopen(reply, …)`, then read the recorded requests |
|
||||||
|
| A reply that fails | `http_error(429)`, `url_error()`, `raw_body("not json")` |
|
||||||
|
| Reading what was sent | `sent_json(request)`, `multipart_fields(request)` |
|
||||||
|
| A program on the PATH | `only_these_tools("pactl", "wl-copy")` |
|
||||||
|
| Audio | `silence()`, `tone()`, `speech()`, `stereo()`, `make_wav()` |
|
||||||
|
| A settings object | `self.config(cleanup_enabled=False)` |
|
||||||
|
|
||||||
|
Three things about this codebase trip up a new test:
|
||||||
|
|
||||||
|
**Signals from a worker thread are never delivered.** `Pipeline`, `MeetingPipeline`
|
||||||
|
and `FileTranscriber` emit from the thread `start()` spawned, which Qt queues
|
||||||
|
until an event loop runs one. Call `_work()` directly instead: it is the same
|
||||||
|
code one frame down, and the signals arrive at once.
|
||||||
|
|
||||||
|
**A level that never moves is not speech.** The silence check is relative, so a
|
||||||
|
steady tone reads as its own noise floor however loud it is. Use `speech()`
|
||||||
|
rather than `tone()` when a recording is meant to have somebody talking in it.
|
||||||
|
|
||||||
|
**`cli.launch_gui` replaces the process.** With no instance running, some verbs
|
||||||
|
`os.execv` into the application, which would take the test run with it. Patch
|
||||||
|
`cli.launch_gui`. `DikteTest` blocks `os.execv` as a backstop, so a test that
|
||||||
|
forgets fails rather than hangs.
|
||||||
|
|
||||||
|
## Another platform
|
||||||
|
|
||||||
|
Most of what Dikte does is not desktop-specific, and the tests are split along
|
||||||
|
that line. 511 of them pass anywhere: transcription, cleanup, the config file,
|
||||||
|
the history, the agent, the command line, the timeline of a meeting. The
|
||||||
|
remaining 59 cover what Dikte *is* on this desktop, and carry `@linux_only`
|
||||||
|
from `tests.support`: PipeWire capture and the pactl device list, wl-clipboard
|
||||||
|
and ydotool, KDE's shortcut file and the `/dev/input` listener.
|
||||||
|
|
||||||
|
Mark a test `@linux_only` when it would fail on a machine that never had those
|
||||||
|
programs. Do not mark one because it happens to be convenient: a test that
|
||||||
|
quietly stops running on the platform you are porting to protects nothing.
|
||||||
|
|
||||||
|
The other half of a port is where the branch goes. Keep `sys.platform` out of
|
||||||
|
the middle of a function; make the public name a chooser and give each platform
|
||||||
|
its own function underneath:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def copy(text):
|
||||||
|
return _copy_macos(text) if sys.platform == "darwin" else _copy_wayland(text)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then each platform's test calls its own function directly and passes everywhere,
|
||||||
|
and adding a third one leaves the first two's tests alone. An `if` buried inside
|
||||||
|
`copy()` forces every existing test to patch `sys.platform` instead, and the
|
||||||
|
next port breaks all of them.
|
||||||
|
|
||||||
|
## What a pull request should carry
|
||||||
|
|
||||||
|
A change to behaviour comes with a test for it. Adding a provider means a test
|
||||||
|
that the request goes to the right URL with the right fields; adding a platform
|
||||||
|
means a test for whatever the parsing of its device list, clipboard or shortcuts
|
||||||
|
looks like. Adding a setting means both halves of `settings_ui.py`: the round
|
||||||
|
trip in `tests/test_ui.py` is what catches only one of them being written.
|
||||||
|
|
||||||
|
Match the surrounding code: it is plain Python with no framework, comments
|
||||||
|
explain why rather than what, and neither the code nor the commit messages use
|
||||||
|
an em dash.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Test-wide safety net, applied before anything under test is imported.
|
||||||
|
|
||||||
|
Every module resolves its paths at import time from the XDG variables, so those
|
||||||
|
are redirected here: a test that forgets to ask for a throwaway directory writes
|
||||||
|
into a temporary one instead of into the real ~/.config/dikte. The Qt platform
|
||||||
|
is pinned for the same reason, so that a machine with no display and a CI runner
|
||||||
|
behave the way a desktop does.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import atexit
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
_SANDBOX = tempfile.mkdtemp(prefix="dikte-tests-")
|
||||||
|
os.environ["XDG_CONFIG_HOME"] = os.path.join(_SANDBOX, "config")
|
||||||
|
os.environ["XDG_DATA_HOME"] = os.path.join(_SANDBOX, "data")
|
||||||
|
atexit.register(shutil.rmtree, _SANDBOX, True)
|
||||||
|
|
||||||
|
# A key sitting in the environment would otherwise reach the code that falls
|
||||||
|
# back to it, and the tests for "there is no key" would pass only on a machine
|
||||||
|
# without one.
|
||||||
|
for _var in ("OPENAI_API_KEY", "OPENROUTER_API_KEY"):
|
||||||
|
os.environ.pop(_var, None)
|
||||||
|
|
||||||
|
# The interface language leaks through module-level state, so the tests fix it
|
||||||
|
# rather than inherit whatever the developer's locale says.
|
||||||
|
for _var in ("LC_ALL", "LC_MESSAGES", "LANG"):
|
||||||
|
os.environ.pop(_var, None)
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
"""What the tests share: a throwaway home, a fake network, small WAV files.
|
||||||
|
|
||||||
|
Two things about this codebase shape all of it. Paths are module-level constants
|
||||||
|
resolved at import time, so they are replaced object by object rather than
|
||||||
|
re-derived by reloading the module, which would hand every other module a second
|
||||||
|
copy of it. And the only way out to the network is urllib, so faking one function
|
||||||
|
is enough to run the whole chain offline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import array
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import urllib.error
|
||||||
|
import wave
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import assistant
|
||||||
|
import config as cfg
|
||||||
|
import i18n
|
||||||
|
|
||||||
|
# What the application is, rather than what it does: PipeWire, wl-clipboard,
|
||||||
|
# ydotool, KDE's shortcut file, /dev/input. A port to another desktop replaces
|
||||||
|
# all of it, and the tests that pin this half say so rather than failing on a
|
||||||
|
# machine that never had any of it.
|
||||||
|
#
|
||||||
|
# Everything else is expected to pass everywhere, and that is the line worth
|
||||||
|
# holding: transcription, cleanup, the config file, the history, the agent, the
|
||||||
|
# command line and the timeline of a meeting are not desktop-specific and must
|
||||||
|
# not become so.
|
||||||
|
linux_only = unittest.skipUnless(
|
||||||
|
sys.platform.startswith("linux"),
|
||||||
|
"covers the Linux desktop stack (PipeWire, wl-clipboard, ydotool, KDE)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _no_exec(*args, **kwargs):
|
||||||
|
raise AssertionError(
|
||||||
|
"a test reached os.execv, which would replace the test process with the "
|
||||||
|
"application; patch cli.launch_gui instead"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DikteTest(unittest.TestCase):
|
||||||
|
"""A test that owns its config, its data directory and its language."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.root = tempfile.mkdtemp(prefix="dikte-test-")
|
||||||
|
self.addCleanup(shutil.rmtree, self.root, True)
|
||||||
|
|
||||||
|
config_dir = self.path("config", "dikte")
|
||||||
|
data_dir = self.path("data", "dikte")
|
||||||
|
self.patch_paths(
|
||||||
|
CONFIG_DIR=config_dir,
|
||||||
|
CONFIG_FILE=config_dir / "config.json",
|
||||||
|
DATA_DIR=data_dir,
|
||||||
|
HISTORY_FILE=data_dir / "history.jsonl",
|
||||||
|
RECORDINGS_DIR=data_dir / "recordings",
|
||||||
|
MEETINGS_DIR=data_dir / "meetings",
|
||||||
|
MEETINGS_FILE=data_dir / "meetings.jsonl",
|
||||||
|
)
|
||||||
|
# Resolved from cfg.DATA_DIR when assistant was imported, so it needs
|
||||||
|
# moving on its own.
|
||||||
|
self.patch_attr(assistant, "SESSION_FILE", data_dir / "assistant.json")
|
||||||
|
|
||||||
|
i18n.set_language("en")
|
||||||
|
self.addCleanup(i18n.set_language, "en")
|
||||||
|
|
||||||
|
# cli.launch_gui replaces this process with the application when no
|
||||||
|
# instance is running. A test that reaches it would take the whole run
|
||||||
|
# with it and hang, so it fails loudly here instead.
|
||||||
|
self.patch_attr(os, "execv", _no_exec)
|
||||||
|
|
||||||
|
# ---- helpers ---------------------------------------------------------
|
||||||
|
|
||||||
|
def path(self, *parts):
|
||||||
|
"""A path inside this test's directory, as a pathlib.Path."""
|
||||||
|
import pathlib
|
||||||
|
return pathlib.Path(self.root, *parts)
|
||||||
|
|
||||||
|
def patch_paths(self, **paths):
|
||||||
|
patcher = mock.patch.multiple(cfg, **paths)
|
||||||
|
patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
|
||||||
|
def patch_attr(self, target, name, value):
|
||||||
|
patcher = mock.patch.object(target, name, value)
|
||||||
|
patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def config(self, **values):
|
||||||
|
"""A Config with nothing stored, then the given settings applied."""
|
||||||
|
conf = cfg.Config()
|
||||||
|
for key, value in values.items():
|
||||||
|
conf[key] = value
|
||||||
|
return conf
|
||||||
|
|
||||||
|
def write_config(self, payload):
|
||||||
|
"""Put a config.json on disk, the way an older version would have."""
|
||||||
|
cfg.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
cfg.CONFIG_FILE.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
|
||||||
|
def read_config_file(self):
|
||||||
|
return json.loads(cfg.CONFIG_FILE.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
# --- the network ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def json_body(payload):
|
||||||
|
"""A stand-in for what urlopen hands back: a context manager that reads."""
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
resp = mock.MagicMock()
|
||||||
|
resp.read.return_value = body
|
||||||
|
resp.__enter__.return_value = resp
|
||||||
|
resp.__exit__.return_value = False
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def raw_body(text):
|
||||||
|
"""The same, for a reply that is not valid JSON."""
|
||||||
|
resp = mock.MagicMock()
|
||||||
|
resp.read.return_value = text.encode("utf-8")
|
||||||
|
resp.__enter__.return_value = resp
|
||||||
|
resp.__exit__.return_value = False
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def http_error(code, body=""):
|
||||||
|
return urllib.error.HTTPError(
|
||||||
|
"https://example.invalid/v1", code, "boom", {},
|
||||||
|
io.BytesIO(body.encode("utf-8")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def url_error(reason="no route to host"):
|
||||||
|
return urllib.error.URLError(reason)
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def fake_urlopen(*replies):
|
||||||
|
"""Answer each call with the next reply; the last one repeats.
|
||||||
|
|
||||||
|
A reply is a payload to encode as JSON, an exception to raise, or an object
|
||||||
|
already shaped like a response. The requests are collected so a test can
|
||||||
|
check what was actually sent.
|
||||||
|
"""
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def opener(req, timeout=None):
|
||||||
|
calls.append(req)
|
||||||
|
reply = replies[min(len(calls) - 1, len(replies) - 1)] if replies else {}
|
||||||
|
if isinstance(reply, Exception):
|
||||||
|
raise reply
|
||||||
|
if isinstance(reply, (dict, list)):
|
||||||
|
return json_body(reply)
|
||||||
|
return reply
|
||||||
|
|
||||||
|
try:
|
||||||
|
with mock.patch("urllib.request.urlopen", side_effect=opener):
|
||||||
|
yield calls
|
||||||
|
finally:
|
||||||
|
# An HTTPError holds a file object and complains when it is collected
|
||||||
|
# without one; the tests raise the same one more than once, so closing
|
||||||
|
# it is the caller's job rather than the code's.
|
||||||
|
for reply in replies:
|
||||||
|
if isinstance(reply, urllib.error.HTTPError):
|
||||||
|
reply.close()
|
||||||
|
|
||||||
|
|
||||||
|
def sent_json(request):
|
||||||
|
"""The JSON body of a recorded request."""
|
||||||
|
return json.loads(request.data.decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def multipart_fields(request):
|
||||||
|
"""{name: value} for the plain fields of a recorded multipart request."""
|
||||||
|
body = request.data.decode("utf-8", "replace")
|
||||||
|
fields = {}
|
||||||
|
for part in body.split("\r\n--"):
|
||||||
|
if 'name="' not in part or "filename=" in part:
|
||||||
|
continue
|
||||||
|
name = part.split('name="', 1)[1].split('"', 1)[0]
|
||||||
|
_, _, value = part.partition("\r\n\r\n")
|
||||||
|
fields[name] = value.rstrip("\r\n")
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
# --- audio ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def pcm(samples):
|
||||||
|
return array.array("h", samples).tobytes()
|
||||||
|
|
||||||
|
|
||||||
|
def tone(seconds, rate=16000, amplitude=8000, channels=1, freq=440.0):
|
||||||
|
"""Interleaved s16 samples for a sine wave, the same on every channel."""
|
||||||
|
frames = int(seconds * rate)
|
||||||
|
out = array.array("h")
|
||||||
|
for index in range(frames):
|
||||||
|
value = int(amplitude * math.sin(2 * math.pi * freq * index / rate))
|
||||||
|
out.extend([value] * channels)
|
||||||
|
return out.tobytes()
|
||||||
|
|
||||||
|
|
||||||
|
def silence(seconds, rate=16000, channels=1):
|
||||||
|
return b"\x00\x00" * int(seconds * rate) * channels
|
||||||
|
|
||||||
|
|
||||||
|
def speech(seconds, rate=16000, amplitude=16000, freq=440.0):
|
||||||
|
"""A buffer the silence check reads as somebody talking.
|
||||||
|
|
||||||
|
A steady tone does not, however loud it is: the check is relative, and a
|
||||||
|
level that never moves is its own noise floor. Speech is quiet, then loud,
|
||||||
|
which is what the pauses between words make it.
|
||||||
|
"""
|
||||||
|
half = seconds / 2
|
||||||
|
return silence(half, rate) + tone(half, rate, amplitude, freq=freq)
|
||||||
|
|
||||||
|
|
||||||
|
def make_wav(path, data, rate=16000, channels=1, width=2):
|
||||||
|
os.makedirs(os.path.dirname(str(path)) or ".", exist_ok=True)
|
||||||
|
with contextlib.closing(wave.open(str(path), "wb")) as wav:
|
||||||
|
wav.setnchannels(channels)
|
||||||
|
wav.setsampwidth(width)
|
||||||
|
wav.setframerate(rate)
|
||||||
|
wav.writeframes(data)
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def stereo(left, right):
|
||||||
|
"""Interleave two equal-length mono buffers into one stereo buffer."""
|
||||||
|
a, b = array.array("h"), array.array("h")
|
||||||
|
a.frombytes(left)
|
||||||
|
b.frombytes(right)
|
||||||
|
out = array.array("h")
|
||||||
|
for first, second in zip(a, b):
|
||||||
|
out.extend((first, second))
|
||||||
|
return out.tobytes()
|
||||||
|
|
||||||
|
|
||||||
|
# --- processes ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCompleted:
|
||||||
|
"""What subprocess.run hands back, as much of it as the code reads."""
|
||||||
|
|
||||||
|
def __init__(self, returncode=0, stdout="", stderr=""):
|
||||||
|
self.returncode = returncode
|
||||||
|
self.stdout = stdout
|
||||||
|
self.stderr = stderr
|
||||||
|
|
||||||
|
|
||||||
|
def only_these_tools(*names):
|
||||||
|
"""shutil.which answers for the named tools and nothing else."""
|
||||||
|
wanted = set(names)
|
||||||
|
return mock.patch("shutil.which", side_effect=lambda tool: (
|
||||||
|
f"/usr/bin/{tool}" if tool in wanted else None))
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
"""The two providers, over a faked urllib.
|
||||||
|
|
||||||
|
Nothing here reaches the network. What is checked is the request that would have
|
||||||
|
gone out, because that is what a new provider changes and what an old one
|
||||||
|
notices: the URL, the headers, the fields of the multipart body, the JSON.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import api
|
||||||
|
from tests.support import (
|
||||||
|
DikteTest,
|
||||||
|
fake_urlopen,
|
||||||
|
http_error,
|
||||||
|
multipart_fields,
|
||||||
|
raw_body,
|
||||||
|
sent_json,
|
||||||
|
url_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
OPENAI = api.Target("openai", "OpenAI", "sk-test", api.OPENAI_URL, "gpt-4o-transcribe")
|
||||||
|
OPENROUTER = api.Target("openrouter", "OpenRouter", "sk-or-test",
|
||||||
|
api.OPENROUTER_URL, "openai/gpt-4o-transcribe")
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampModel(unittest.TestCase):
|
||||||
|
def test_only_whisper_returns_segment_times(self):
|
||||||
|
self.assertEqual(api.timestamp_model("openai"), "whisper-1")
|
||||||
|
|
||||||
|
def test_openrouter_namespaces_the_id(self):
|
||||||
|
self.assertEqual(api.timestamp_model("openrouter"), "openai/whisper-1")
|
||||||
|
|
||||||
|
|
||||||
|
class Explain(DikteTest):
|
||||||
|
def error(self, status):
|
||||||
|
return api.explain(api.ApiError("HTTP", status), "OpenAI")
|
||||||
|
|
||||||
|
def test_a_rejected_key_points_at_the_settings(self):
|
||||||
|
for status in (401, 403):
|
||||||
|
with self.subTest(status=status):
|
||||||
|
message = str(self.error(status))
|
||||||
|
self.assertIn("OpenAI", message)
|
||||||
|
self.assertIn("Settings", message)
|
||||||
|
|
||||||
|
def test_no_credit(self):
|
||||||
|
self.assertIn("credit", str(self.error(402)))
|
||||||
|
|
||||||
|
def test_rate_limited(self):
|
||||||
|
self.assertIn("rate limiting", str(self.error(429)))
|
||||||
|
|
||||||
|
def test_anything_else_keeps_the_original_text(self):
|
||||||
|
explained = api.explain(api.ApiError("something broke", 500), "OpenRouter")
|
||||||
|
self.assertIn("something broke", str(explained))
|
||||||
|
self.assertEqual(explained.status, 500)
|
||||||
|
|
||||||
|
def test_the_status_is_carried_through(self):
|
||||||
|
self.assertEqual(self.error(429).status, 429)
|
||||||
|
|
||||||
|
|
||||||
|
class ExtractError(unittest.TestCase):
|
||||||
|
def test_the_usual_shape(self):
|
||||||
|
body = json.dumps({"error": {"message": "invalid model"}})
|
||||||
|
self.assertEqual(api._extract_error(body), "invalid model")
|
||||||
|
|
||||||
|
def test_an_error_that_is_a_plain_string(self):
|
||||||
|
self.assertEqual(api._extract_error(json.dumps({"error": "nope"})), "nope")
|
||||||
|
|
||||||
|
def test_an_error_object_with_no_message(self):
|
||||||
|
body = json.dumps({"error": {"code": 42}})
|
||||||
|
self.assertIn("42", api._extract_error(body))
|
||||||
|
|
||||||
|
def test_a_body_that_is_not_json(self):
|
||||||
|
self.assertEqual(api._extract_error("<html>502</html>"), "<html>502</html>")
|
||||||
|
|
||||||
|
def test_a_wall_of_html_is_cut_short(self):
|
||||||
|
self.assertEqual(len(api._extract_error("x" * 5000)), 300)
|
||||||
|
|
||||||
|
|
||||||
|
class Multipart(DikteTest):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.wav = str(self.path("clip.wav"))
|
||||||
|
os.makedirs(self.root, exist_ok=True)
|
||||||
|
with open(self.wav, "wb") as fh:
|
||||||
|
fh.write(b"RIFFfake")
|
||||||
|
|
||||||
|
def build(self, fields):
|
||||||
|
return api._multipart(fields, "file", self.wav)
|
||||||
|
|
||||||
|
def test_the_boundary_is_declared_and_used(self):
|
||||||
|
body, ctype = self.build([("model", "whisper-1")])
|
||||||
|
boundary = ctype.split("boundary=")[1]
|
||||||
|
self.assertTrue(ctype.startswith("multipart/form-data"))
|
||||||
|
self.assertIn(boundary.encode(), body)
|
||||||
|
self.assertTrue(body.endswith(f"--{boundary}--\r\n".encode()))
|
||||||
|
|
||||||
|
def test_a_field_is_named_and_carries_its_value(self):
|
||||||
|
body, _ = self.build([("model", "whisper-1")])
|
||||||
|
self.assertIn(b'name="model"', body)
|
||||||
|
self.assertIn(b"whisper-1", body)
|
||||||
|
|
||||||
|
def test_empty_fields_are_left_out(self):
|
||||||
|
body, _ = self.build([("model", "whisper-1"), ("language", ""),
|
||||||
|
("prompt", None)])
|
||||||
|
self.assertNotIn(b'name="language"', body)
|
||||||
|
self.assertNotIn(b'name="prompt"', body)
|
||||||
|
|
||||||
|
def test_the_file_goes_in_with_its_name_and_type(self):
|
||||||
|
body, _ = self.build([])
|
||||||
|
self.assertIn(b'filename="clip.wav"', body)
|
||||||
|
self.assertIn(b"Content-Type: audio/x-wav", body)
|
||||||
|
self.assertIn(b"RIFFfake", body)
|
||||||
|
|
||||||
|
def test_a_boundary_is_not_reused_between_requests(self):
|
||||||
|
first, _ = self.build([])
|
||||||
|
second, _ = self.build([])
|
||||||
|
self.assertNotEqual(first, second)
|
||||||
|
|
||||||
|
|
||||||
|
class Headers(unittest.TestCase):
|
||||||
|
def test_the_key_is_a_bearer_token(self):
|
||||||
|
self.assertEqual(api._headers("openai", "sk-test")["Authorization"],
|
||||||
|
"Bearer sk-test")
|
||||||
|
|
||||||
|
def test_openai_gets_no_extras(self):
|
||||||
|
self.assertNotIn("HTTP-Referer", api._headers("openai", "sk-test"))
|
||||||
|
|
||||||
|
def test_openrouter_is_told_who_is_calling(self):
|
||||||
|
headers = api._headers("openrouter", "sk-or-test")
|
||||||
|
self.assertEqual(headers["HTTP-Referer"], api.APP_URL)
|
||||||
|
self.assertEqual(headers["X-Title"], "Dikte")
|
||||||
|
|
||||||
|
def test_a_content_type_is_added_when_there_is_a_body(self):
|
||||||
|
headers = api._headers("openai", "k", "application/json")
|
||||||
|
self.assertEqual(headers["Content-Type"], "application/json")
|
||||||
|
|
||||||
|
|
||||||
|
class Transcribe(DikteTest):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.wav = str(self.path("clip.wav"))
|
||||||
|
os.makedirs(self.root, exist_ok=True)
|
||||||
|
with open(self.wav, "wb") as fh:
|
||||||
|
fh.write(b"RIFFfake")
|
||||||
|
|
||||||
|
def test_the_transcript_comes_back_stripped(self):
|
||||||
|
with fake_urlopen({"text": " hello there \n"}):
|
||||||
|
self.assertEqual(api.transcribe(OPENAI, self.wav), "hello there")
|
||||||
|
|
||||||
|
def test_it_goes_to_the_transcriptions_endpoint(self):
|
||||||
|
with fake_urlopen({"text": "hi"}) as calls:
|
||||||
|
api.transcribe(OPENAI, self.wav)
|
||||||
|
self.assertEqual(calls[0].full_url,
|
||||||
|
"https://api.openai.com/v1/audio/transcriptions")
|
||||||
|
|
||||||
|
def test_a_custom_base_url_is_honoured(self):
|
||||||
|
target = OPENAI._replace(base_url="http://localhost:8080/v1/")
|
||||||
|
with fake_urlopen({"text": "hi"}) as calls:
|
||||||
|
api.transcribe(target, self.wav)
|
||||||
|
self.assertEqual(calls[0].full_url,
|
||||||
|
"http://localhost:8080/v1/audio/transcriptions")
|
||||||
|
|
||||||
|
def test_the_model_and_the_format_are_sent(self):
|
||||||
|
with fake_urlopen({"text": "hi"}) as calls:
|
||||||
|
api.transcribe(OPENAI, self.wav)
|
||||||
|
fields = multipart_fields(calls[0])
|
||||||
|
self.assertEqual(fields["model"], "gpt-4o-transcribe")
|
||||||
|
self.assertEqual(fields["response_format"], "json")
|
||||||
|
|
||||||
|
def test_a_language_is_sent_but_auto_is_not(self):
|
||||||
|
with fake_urlopen({"text": "hi"}) as calls:
|
||||||
|
api.transcribe(OPENAI, self.wav, language="tr")
|
||||||
|
api.transcribe(OPENAI, self.wav, language="auto")
|
||||||
|
self.assertEqual(multipart_fields(calls[0])["language"], "tr")
|
||||||
|
self.assertNotIn("language", multipart_fields(calls[1]))
|
||||||
|
|
||||||
|
def test_the_glossary_goes_to_openai_only(self):
|
||||||
|
"""OpenRouter takes the field and throws it away, so spare it the bytes."""
|
||||||
|
with fake_urlopen({"text": "hi"}) as calls:
|
||||||
|
api.transcribe(OPENAI, self.wav, prompt="Paraşüt, OpenFrame")
|
||||||
|
api.transcribe(OPENROUTER, self.wav, prompt="Paraşüt, OpenFrame")
|
||||||
|
self.assertIn("prompt", multipart_fields(calls[0]))
|
||||||
|
self.assertNotIn("prompt", multipart_fields(calls[1]))
|
||||||
|
|
||||||
|
def test_openrouter_is_attributed(self):
|
||||||
|
with fake_urlopen({"text": "hi"}) as calls:
|
||||||
|
api.transcribe(OPENROUTER, self.wav)
|
||||||
|
self.assertEqual(calls[0].get_header("X-title"), "Dikte")
|
||||||
|
|
||||||
|
def test_no_key_at_all(self):
|
||||||
|
with self.assertRaises(api.ApiError) as caught:
|
||||||
|
api.transcribe(OPENAI._replace(api_key=""), self.wav)
|
||||||
|
self.assertIn("OpenAI", str(caught.exception))
|
||||||
|
|
||||||
|
def test_an_empty_transcript_is_an_error(self):
|
||||||
|
with fake_urlopen({"text": " "}), self.assertRaises(api.ApiError):
|
||||||
|
api.transcribe(OPENAI, self.wav)
|
||||||
|
|
||||||
|
def test_a_rejected_key_is_explained_in_the_provider_s_name(self):
|
||||||
|
with fake_urlopen(http_error(401, '{"error": {"message": "bad key"}}')), \
|
||||||
|
self.assertRaises(api.ApiError) as caught:
|
||||||
|
api.transcribe(OPENROUTER, self.wav)
|
||||||
|
self.assertIn("OpenRouter", str(caught.exception))
|
||||||
|
self.assertEqual(caught.exception.status, 401)
|
||||||
|
|
||||||
|
def test_no_network(self):
|
||||||
|
with fake_urlopen(url_error("name or service not known")), \
|
||||||
|
self.assertRaises(api.ApiError) as caught:
|
||||||
|
api.transcribe(OPENAI, self.wav)
|
||||||
|
self.assertIn("connect", str(caught.exception))
|
||||||
|
|
||||||
|
def test_a_reply_that_is_not_json(self):
|
||||||
|
with fake_urlopen(raw_body("<html>bad gateway</html>")), \
|
||||||
|
self.assertRaises(api.ApiError) as caught:
|
||||||
|
api.transcribe(OPENAI, self.wav)
|
||||||
|
self.assertIn("parse", str(caught.exception))
|
||||||
|
|
||||||
|
|
||||||
|
class TranscribeSegments(DikteTest):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.wav = str(self.path("clip.wav"))
|
||||||
|
os.makedirs(self.root, exist_ok=True)
|
||||||
|
with open(self.wav, "wb") as fh:
|
||||||
|
fh.write(b"RIFFfake")
|
||||||
|
|
||||||
|
def reply(self, segments, text=""):
|
||||||
|
return {"segments": segments, "text": text}
|
||||||
|
|
||||||
|
def test_it_switches_to_the_model_that_has_timestamps(self):
|
||||||
|
with fake_urlopen(self.reply([{"start": 0, "end": 1, "text": "hi"}])) as calls:
|
||||||
|
api.transcribe_segments(OPENAI, self.wav)
|
||||||
|
fields = multipart_fields(calls[0])
|
||||||
|
self.assertEqual(fields["model"], "whisper-1")
|
||||||
|
self.assertEqual(fields["response_format"], "verbose_json")
|
||||||
|
self.assertEqual(fields["timestamp_granularities[]"], "segment")
|
||||||
|
|
||||||
|
def test_openrouter_uses_the_namespaced_id(self):
|
||||||
|
with fake_urlopen(self.reply([{"start": 0, "end": 1, "text": "hi"}])) as calls:
|
||||||
|
api.transcribe_segments(OPENROUTER, self.wav)
|
||||||
|
self.assertEqual(multipart_fields(calls[0])["model"], "openai/whisper-1")
|
||||||
|
|
||||||
|
def test_the_segments_come_back_as_numbers(self):
|
||||||
|
with fake_urlopen(self.reply([
|
||||||
|
{"start": "0.5", "end": "2.25", "text": " hello "},
|
||||||
|
{"start": 2.25, "end": 4.0, "text": "there"},
|
||||||
|
])):
|
||||||
|
segments = api.transcribe_segments(OPENAI, self.wav)
|
||||||
|
self.assertEqual(segments, [(0.5, 2.25, "hello"), (2.25, 4.0, "there")])
|
||||||
|
|
||||||
|
def test_empty_segments_are_dropped(self):
|
||||||
|
with fake_urlopen(self.reply([
|
||||||
|
{"start": 0, "end": 1, "text": " "},
|
||||||
|
{"start": 1, "end": 2, "text": "real"},
|
||||||
|
])):
|
||||||
|
self.assertEqual(api.transcribe_segments(OPENAI, self.wav),
|
||||||
|
[(1.0, 2.0, "real")])
|
||||||
|
|
||||||
|
def test_an_end_before_its_start_is_pulled_forward(self):
|
||||||
|
with fake_urlopen(self.reply([{"start": 5, "end": 1, "text": "hi"}])):
|
||||||
|
self.assertEqual(api.transcribe_segments(OPENAI, self.wav),
|
||||||
|
[(5.0, 5.0, "hi")])
|
||||||
|
|
||||||
|
def test_a_model_that_returned_no_segments_still_gives_its_text(self):
|
||||||
|
with fake_urlopen(self.reply([], text="the whole thing")):
|
||||||
|
self.assertEqual(api.transcribe_segments(OPENAI, self.wav),
|
||||||
|
[(0.0, 0.0, "the whole thing")])
|
||||||
|
|
||||||
|
def test_nothing_at_all(self):
|
||||||
|
with fake_urlopen(self.reply([], text="")), \
|
||||||
|
self.assertRaises(api.ApiError):
|
||||||
|
api.transcribe_segments(OPENAI, self.wav)
|
||||||
|
|
||||||
|
|
||||||
|
def chat_reply(content):
|
||||||
|
return {"choices": [{"message": {"content": content}}]}
|
||||||
|
|
||||||
|
|
||||||
|
class Cleanup(DikteTest):
|
||||||
|
def call(self, replies, **kwargs):
|
||||||
|
with fake_urlopen(replies) as calls:
|
||||||
|
result = api.cleanup("uh, hello", "sk-or-test", "some/model",
|
||||||
|
"you clean up text", **kwargs)
|
||||||
|
return result, calls
|
||||||
|
|
||||||
|
def test_the_cleaned_text_comes_back(self):
|
||||||
|
result, _ = self.call(chat_reply(" Hello. "))
|
||||||
|
self.assertEqual(result, "Hello.")
|
||||||
|
|
||||||
|
def test_it_goes_to_chat_completions(self):
|
||||||
|
_, calls = self.call(chat_reply("Hello."))
|
||||||
|
self.assertEqual(calls[0].full_url,
|
||||||
|
"https://openrouter.ai/api/v1/chat/completions")
|
||||||
|
|
||||||
|
def test_the_prompt_and_the_transcript_are_kept_apart(self):
|
||||||
|
_, calls = self.call(chat_reply("Hello."))
|
||||||
|
payload = sent_json(calls[0])
|
||||||
|
self.assertEqual(payload["messages"][0]["role"], "system")
|
||||||
|
self.assertEqual(payload["messages"][0]["content"], "you clean up text")
|
||||||
|
self.assertIn("<transcript>", payload["messages"][1]["content"])
|
||||||
|
self.assertIn("uh, hello", payload["messages"][1]["content"])
|
||||||
|
|
||||||
|
def test_the_temperature_is_pinned(self):
|
||||||
|
_, calls = self.call(chat_reply("Hello."))
|
||||||
|
self.assertEqual(sent_json(calls[0])["temperature"], 0)
|
||||||
|
|
||||||
|
def test_no_effort_asked_for_means_no_reasoning_block(self):
|
||||||
|
_, calls = self.call(chat_reply("Hello."))
|
||||||
|
self.assertNotIn("reasoning", sent_json(calls[0]))
|
||||||
|
|
||||||
|
def test_an_effort_is_passed_on_and_the_thinking_left_out(self):
|
||||||
|
_, calls = self.call(chat_reply("Hello."), reasoning="high")
|
||||||
|
self.assertEqual(sent_json(calls[0])["reasoning"],
|
||||||
|
{"effort": "high", "exclude": True})
|
||||||
|
|
||||||
|
def test_a_local_base_url(self):
|
||||||
|
_, calls = self.call(chat_reply("Hello."), base_url="http://localhost:1234/v1")
|
||||||
|
self.assertEqual(calls[0].full_url, "http://localhost:1234/v1/chat/completions")
|
||||||
|
|
||||||
|
def test_no_key(self):
|
||||||
|
with self.assertRaises(api.ApiError):
|
||||||
|
api.cleanup("hello", "", "some/model", "prompt")
|
||||||
|
|
||||||
|
def test_a_reply_with_no_choices_says_why(self):
|
||||||
|
with fake_urlopen({"error": {"message": "model is offline"}}), \
|
||||||
|
self.assertRaises(api.ApiError) as caught:
|
||||||
|
api.cleanup("hello", "k", "m", "p")
|
||||||
|
self.assertIn("model is offline", str(caught.exception))
|
||||||
|
|
||||||
|
def test_an_empty_answer(self):
|
||||||
|
with fake_urlopen(chat_reply(" ")), self.assertRaises(api.ApiError):
|
||||||
|
api.cleanup("hello", "k", "m", "p")
|
||||||
|
|
||||||
|
def test_a_rate_limit_is_explained(self):
|
||||||
|
with fake_urlopen(http_error(429)), \
|
||||||
|
self.assertRaises(api.ApiError) as caught:
|
||||||
|
api.cleanup("hello", "k", "m", "p")
|
||||||
|
self.assertIn("OpenRouter", str(caught.exception))
|
||||||
|
|
||||||
|
|
||||||
|
class Chat(DikteTest):
|
||||||
|
def test_the_history_is_sent_after_the_system_prompt(self):
|
||||||
|
history = [{"role": "user", "content": "book it"},
|
||||||
|
{"role": "assistant", "content": "done"}]
|
||||||
|
with fake_urlopen(chat_reply("moved it")) as calls:
|
||||||
|
api.chat(history + [{"role": "user", "content": "move it"}],
|
||||||
|
"k", "some/model", "you are an agent")
|
||||||
|
payload = sent_json(calls[0])
|
||||||
|
self.assertEqual(payload["messages"][0],
|
||||||
|
{"role": "system", "content": "you are an agent"})
|
||||||
|
self.assertEqual(payload["messages"][1:], history +
|
||||||
|
[{"role": "user", "content": "move it"}])
|
||||||
|
|
||||||
|
def test_no_temperature_is_forced_on_a_conversation(self):
|
||||||
|
with fake_urlopen(chat_reply("hi")) as calls:
|
||||||
|
api.chat([{"role": "user", "content": "hi"}], "k", "m", "p")
|
||||||
|
self.assertNotIn("temperature", sent_json(calls[0]))
|
||||||
|
|
||||||
|
def test_no_key(self):
|
||||||
|
with self.assertRaises(api.ApiError):
|
||||||
|
api.chat([], "", "m", "p")
|
||||||
|
|
||||||
|
def test_an_empty_answer(self):
|
||||||
|
with fake_urlopen(chat_reply("")), self.assertRaises(api.ApiError):
|
||||||
|
api.chat([{"role": "user", "content": "hi"}], "k", "m", "p")
|
||||||
|
|
||||||
|
|
||||||
|
class KeyStatus(DikteTest):
|
||||||
|
def test_a_key_with_no_limit(self):
|
||||||
|
with fake_urlopen({"data": {"limit": None, "usage": 3}}):
|
||||||
|
self.assertIn("no spending limit",
|
||||||
|
api.openrouter_key_status("sk-or-test"))
|
||||||
|
|
||||||
|
def test_a_key_with_a_limit_reports_both_numbers(self):
|
||||||
|
with fake_urlopen({"data": {"limit": 10, "usage": 2.5}}):
|
||||||
|
message = api.openrouter_key_status("sk-or-test")
|
||||||
|
self.assertIn("2.5", message)
|
||||||
|
self.assertIn("10", message)
|
||||||
|
|
||||||
|
def test_no_key(self):
|
||||||
|
with self.assertRaises(api.ApiError):
|
||||||
|
api.openrouter_key_status("")
|
||||||
|
|
||||||
|
def test_a_key_the_service_rejects(self):
|
||||||
|
with fake_urlopen(http_error(401)), \
|
||||||
|
self.assertRaises(api.ApiError) as caught:
|
||||||
|
api.openrouter_key_status("sk-or-bad")
|
||||||
|
self.assertEqual(caught.exception.status, 401)
|
||||||
|
|
||||||
|
|
||||||
|
class ModelLists(DikteTest):
|
||||||
|
def test_openrouter_returns_sorted_ids(self):
|
||||||
|
with fake_urlopen({"data": [{"id": "z/model"}, {"id": "a/model"}]}):
|
||||||
|
self.assertEqual(api.openrouter_models(), ["a/model", "z/model"])
|
||||||
|
|
||||||
|
def test_the_model_list_needs_no_key(self):
|
||||||
|
with fake_urlopen({"data": []}) as calls:
|
||||||
|
api.openrouter_models()
|
||||||
|
self.assertIsNone(calls[0].get_header("Authorization"))
|
||||||
|
|
||||||
|
def test_a_key_is_sent_when_there_is_one(self):
|
||||||
|
with fake_urlopen({"data": []}) as calls:
|
||||||
|
api.openrouter_models("sk-or-test")
|
||||||
|
self.assertEqual(calls[0].get_header("Authorization"), "Bearer sk-or-test")
|
||||||
|
|
||||||
|
def test_speech_models_are_asked_for_and_filtered_again(self):
|
||||||
|
"""A query parameter the API stops honouring must not leak the lot."""
|
||||||
|
with fake_urlopen({"data": [
|
||||||
|
{"id": "openai/whisper-1",
|
||||||
|
"architecture": {"output_modalities": ["transcription"]}},
|
||||||
|
{"id": "google/gemini-3.5-flash",
|
||||||
|
"architecture": {"output_modalities": ["text"]}},
|
||||||
|
{"id": "broken/model"},
|
||||||
|
]}) as calls:
|
||||||
|
models = api.openrouter_models(transcription=True)
|
||||||
|
self.assertIn("output_modalities=transcription", calls[0].full_url)
|
||||||
|
self.assertEqual(models, ["openai/whisper-1"])
|
||||||
|
|
||||||
|
def test_openai_narrows_to_the_audio_models(self):
|
||||||
|
with fake_urlopen({"data": [{"id": "gpt-4o"}, {"id": "whisper-1"},
|
||||||
|
{"id": "gpt-4o-transcribe"}]}):
|
||||||
|
self.assertEqual(api.openai_models("sk-test"),
|
||||||
|
["gpt-4o-transcribe", "whisper-1"])
|
||||||
|
|
||||||
|
def test_a_list_with_no_audio_models_is_shown_whole(self):
|
||||||
|
with fake_urlopen({"data": [{"id": "gpt-4o"}, {"id": "o3"}]}):
|
||||||
|
self.assertEqual(api.openai_models("sk-test"), ["gpt-4o", "o3"])
|
||||||
|
|
||||||
|
def test_openai_needs_a_key(self):
|
||||||
|
with self.assertRaises(api.ApiError):
|
||||||
|
api.openai_models("")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
"""Handing a dictation to an agent.
|
||||||
|
|
||||||
|
Three providers behind one setting, so most of this is about the command line
|
||||||
|
each of them is given and about the conversation carried between dictations. The
|
||||||
|
CLIs are faked at subprocess.Popen: what the tests read is the argument list and
|
||||||
|
what the stream of JSON events is turned into.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import assistant
|
||||||
|
from tests.support import DikteTest, fake_urlopen, only_these_tools
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCli:
|
||||||
|
"""A CLI that prints the given events and exits."""
|
||||||
|
|
||||||
|
def __init__(self, events=(), code=0, stderr="", noise=()):
|
||||||
|
lines = list(noise) + [json.dumps(event) for event in events]
|
||||||
|
self.stdout = io.StringIO("\n".join(lines) + "\n")
|
||||||
|
self.stderr = io.StringIO(stderr)
|
||||||
|
self.returncode = code
|
||||||
|
self.killed = False
|
||||||
|
|
||||||
|
def poll(self):
|
||||||
|
return self.returncode
|
||||||
|
|
||||||
|
def wait(self, timeout=None):
|
||||||
|
return self.returncode
|
||||||
|
|
||||||
|
def terminate(self):
|
||||||
|
self.killed = True
|
||||||
|
|
||||||
|
def kill(self):
|
||||||
|
self.killed = True
|
||||||
|
|
||||||
|
|
||||||
|
class Provider(DikteTest):
|
||||||
|
def test_the_default(self):
|
||||||
|
self.assertEqual(assistant.provider(self.config()), "claude")
|
||||||
|
|
||||||
|
def test_a_provider_this_version_does_not_have(self):
|
||||||
|
self.assertEqual(
|
||||||
|
assistant.provider(self.config(assistant_provider="ollama")), "claude")
|
||||||
|
|
||||||
|
def test_each_one_is_recognised(self):
|
||||||
|
for name in assistant.PROVIDERS:
|
||||||
|
with self.subTest(name=name):
|
||||||
|
self.assertEqual(
|
||||||
|
assistant.provider(self.config(assistant_provider=name)), name)
|
||||||
|
|
||||||
|
def test_what_each_one_runs(self):
|
||||||
|
self.assertEqual(assistant.executable("claude"), "claude")
|
||||||
|
self.assertEqual(assistant.executable("codex"), "codex")
|
||||||
|
self.assertEqual(assistant.executable("openrouter"), "")
|
||||||
|
|
||||||
|
def test_what_each_one_is_called(self):
|
||||||
|
self.assertEqual(assistant.display_name(self.config()), "Claude")
|
||||||
|
self.assertEqual(
|
||||||
|
assistant.display_name(self.config(assistant_provider="codex")), "Codex")
|
||||||
|
self.assertEqual(
|
||||||
|
assistant.display_name(self.config(assistant_provider="openrouter")),
|
||||||
|
"OpenRouter")
|
||||||
|
|
||||||
|
|
||||||
|
class Effort(unittest.TestCase):
|
||||||
|
"""One scale, offered once; a rung a provider lacks lands on its nearest."""
|
||||||
|
|
||||||
|
def test_the_scales_cover_the_same_settings(self):
|
||||||
|
self.assertEqual(set(assistant.CLAUDE_EFFORT), set(assistant.CODEX_EFFORT))
|
||||||
|
|
||||||
|
def test_codex_has_no_rung_above_high(self):
|
||||||
|
self.assertEqual(assistant.CODEX_EFFORT["xhigh"], "high")
|
||||||
|
self.assertEqual(assistant.CODEX_EFFORT["max"], "high")
|
||||||
|
|
||||||
|
def test_claude_has_no_rung_below_low(self):
|
||||||
|
self.assertEqual(assistant.CLAUDE_EFFORT["none"], "low")
|
||||||
|
self.assertEqual(assistant.CLAUDE_EFFORT["minimal"], "low")
|
||||||
|
|
||||||
|
def test_an_empty_setting_asks_for_nothing(self):
|
||||||
|
self.assertEqual(assistant.CLAUDE_EFFORT.get("", ""), "")
|
||||||
|
self.assertEqual(assistant.CODEX_EFFORT.get("", ""), "")
|
||||||
|
|
||||||
|
|
||||||
|
class Session(DikteTest):
|
||||||
|
def test_nothing_stored_yet(self):
|
||||||
|
self.assertEqual(assistant.read_session("claude", 1800), "")
|
||||||
|
self.assertEqual(assistant.read_messages("openrouter", 1800), [])
|
||||||
|
self.assertEqual(assistant.stored_provider(), "")
|
||||||
|
self.assertIsNone(assistant.session_age())
|
||||||
|
|
||||||
|
def test_an_id_is_written_and_read_back(self):
|
||||||
|
assistant.write_session("claude", "abc-123")
|
||||||
|
self.assertEqual(assistant.read_session("claude", 1800), "abc-123")
|
||||||
|
self.assertEqual(assistant.stored_provider(), "claude")
|
||||||
|
|
||||||
|
def test_nobody_picks_up_another_provider_s_thread(self):
|
||||||
|
assistant.write_session("claude", "abc-123")
|
||||||
|
self.assertEqual(assistant.read_session("codex", 1800), "")
|
||||||
|
|
||||||
|
def test_a_conversation_that_has_sat_unused_is_dropped(self):
|
||||||
|
assistant.write_session("claude", "abc-123")
|
||||||
|
with mock.patch.object(time, "time", return_value=time.time() + 3600):
|
||||||
|
self.assertEqual(assistant.read_session("claude", 1800), "")
|
||||||
|
|
||||||
|
def test_a_session_that_never_expires(self):
|
||||||
|
assistant.write_session("claude", "abc-123")
|
||||||
|
with mock.patch.object(time, "time", return_value=time.time() + 10 ** 6):
|
||||||
|
self.assertEqual(assistant.read_session("claude", 0), "abc-123")
|
||||||
|
|
||||||
|
def test_the_messages_of_the_provider_that_keeps_none(self):
|
||||||
|
messages = [{"role": "user", "content": "hi"}]
|
||||||
|
assistant.write_session("openrouter", messages=messages)
|
||||||
|
self.assertEqual(assistant.read_messages("openrouter", 1800), messages)
|
||||||
|
|
||||||
|
def test_the_history_window_ends_somewhere(self):
|
||||||
|
messages = [{"role": "user", "content": str(index)} for index in range(50)]
|
||||||
|
assistant.write_session("openrouter", messages=messages)
|
||||||
|
stored = assistant.read_messages("openrouter", 1800)
|
||||||
|
self.assertEqual(len(stored), assistant.MAX_HISTORY)
|
||||||
|
self.assertEqual(stored[-1]["content"], "49")
|
||||||
|
|
||||||
|
def test_the_age_of_the_conversation(self):
|
||||||
|
assistant.write_session("claude", "abc-123")
|
||||||
|
self.assertLess(assistant.session_age(), 5)
|
||||||
|
|
||||||
|
def test_a_row_with_neither_an_id_nor_messages_has_no_age(self):
|
||||||
|
assistant.write_session("claude", "")
|
||||||
|
self.assertIsNone(assistant.session_age())
|
||||||
|
|
||||||
|
def test_clearing(self):
|
||||||
|
assistant.write_session("claude", "abc-123")
|
||||||
|
assistant.clear_session()
|
||||||
|
self.assertEqual(assistant.read_session("claude", 1800), "")
|
||||||
|
|
||||||
|
def test_clearing_one_that_is_not_there(self):
|
||||||
|
assistant.clear_session() # must not raise
|
||||||
|
|
||||||
|
def test_a_session_file_that_is_not_json(self):
|
||||||
|
assistant.SESSION_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
assistant.SESSION_FILE.write_text("{oh dear", encoding="utf-8")
|
||||||
|
self.assertEqual(assistant.read_session("claude", 1800), "")
|
||||||
|
self.assertEqual(assistant.stored_provider(), "")
|
||||||
|
self.assertIsNone(assistant.session_age())
|
||||||
|
|
||||||
|
|
||||||
|
class WorkingDir(DikteTest):
|
||||||
|
def test_the_home_directory_by_default(self):
|
||||||
|
self.assertEqual(assistant.working_dir(self.config()),
|
||||||
|
os.path.expanduser("~"))
|
||||||
|
|
||||||
|
def test_a_directory_of_your_own(self):
|
||||||
|
conf = self.config(assistant_dir=self.root)
|
||||||
|
self.assertEqual(assistant.working_dir(conf), self.root)
|
||||||
|
|
||||||
|
def test_a_tilde_is_expanded(self):
|
||||||
|
conf = self.config(assistant_dir="~")
|
||||||
|
self.assertEqual(assistant.working_dir(conf), os.path.expanduser("~"))
|
||||||
|
|
||||||
|
def test_a_directory_that_is_not_there_falls_back(self):
|
||||||
|
conf = self.config(assistant_dir="/no/such/place")
|
||||||
|
self.assertEqual(assistant.working_dir(conf), os.path.expanduser("~"))
|
||||||
|
|
||||||
|
|
||||||
|
class Labels(DikteTest):
|
||||||
|
def test_a_tool_the_table_knows(self):
|
||||||
|
self.assertEqual(assistant._claude_label({"name": "Bash"}),
|
||||||
|
"Running a command…")
|
||||||
|
|
||||||
|
def test_a_tool_arriving_from_an_mcp_server_is_named_by_its_server(self):
|
||||||
|
self.assertIn("gmail", assistant._claude_label({"name": "mcp__gmail__send"}))
|
||||||
|
|
||||||
|
def test_a_skill_is_named_by_the_skill(self):
|
||||||
|
label = assistant._claude_label(
|
||||||
|
{"name": "Skill", "input": {"skill": "calendar"}})
|
||||||
|
self.assertIn("calendar", label)
|
||||||
|
|
||||||
|
def test_a_tool_nobody_wrote_a_line_for(self):
|
||||||
|
self.assertIn("SomeNewTool",
|
||||||
|
assistant._claude_label({"name": "SomeNewTool"}))
|
||||||
|
|
||||||
|
def test_a_tool_with_no_name_at_all(self):
|
||||||
|
self.assertTrue(assistant._claude_label({}))
|
||||||
|
|
||||||
|
def test_the_codex_table(self):
|
||||||
|
self.assertEqual(assistant._codex_label({"type": "command_execution"}),
|
||||||
|
"Running a command…")
|
||||||
|
|
||||||
|
def test_a_codex_mcp_call(self):
|
||||||
|
self.assertIn("gmail", assistant._codex_label(
|
||||||
|
{"type": "mcp_tool_call", "server": "gmail"}))
|
||||||
|
|
||||||
|
def test_a_codex_item_nobody_listed(self):
|
||||||
|
self.assertIn("something_new",
|
||||||
|
assistant._codex_label({"type": "something_new"}))
|
||||||
|
|
||||||
|
|
||||||
|
class Denials(DikteTest):
|
||||||
|
def test_nothing_was_denied(self):
|
||||||
|
self.assertEqual(assistant._denial_warning({}), "")
|
||||||
|
self.assertEqual(assistant._denial_warning({"permission_denials": []}), "")
|
||||||
|
|
||||||
|
def test_a_denied_tool_is_named(self):
|
||||||
|
warning = assistant._denial_warning(
|
||||||
|
{"permission_denials": [{"tool_name": "Bash"}]})
|
||||||
|
self.assertIn("Bash", warning)
|
||||||
|
|
||||||
|
def test_the_same_tool_denied_twice_is_named_once(self):
|
||||||
|
warning = assistant._denial_warning({"permission_denials": [
|
||||||
|
{"tool_name": "Bash"}, {"tool_name": "Bash"}, {"tool_name": "Write"}]})
|
||||||
|
self.assertEqual(warning.count("Bash"), 1)
|
||||||
|
self.assertIn("Write", warning)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionMissing(unittest.TestCase):
|
||||||
|
def test_a_session_that_is_gone(self):
|
||||||
|
for text in ("Error: session abc not found",
|
||||||
|
"No conversation with that id",
|
||||||
|
"unknown thread: abc"):
|
||||||
|
with self.subTest(text=text):
|
||||||
|
self.assertTrue(assistant._session_missing(text))
|
||||||
|
|
||||||
|
def test_an_unrelated_failure(self):
|
||||||
|
for text in ("", "network unreachable", "session limit exceeded"):
|
||||||
|
with self.subTest(text=text):
|
||||||
|
self.assertFalse(assistant._session_missing(text))
|
||||||
|
|
||||||
|
def test_the_last_line_is_the_one_worth_showing(self):
|
||||||
|
self.assertEqual(assistant._last_line("warning\n\nreal error\n"),
|
||||||
|
"real error")
|
||||||
|
self.assertEqual(assistant._last_line(""), "")
|
||||||
|
self.assertEqual(assistant._last_line(None), "")
|
||||||
|
|
||||||
|
|
||||||
|
class Conclude(DikteTest):
|
||||||
|
def found(self, **changes):
|
||||||
|
row = {"answer": "", "warning": "", "session": "", "failure": ""}
|
||||||
|
row.update(changes)
|
||||||
|
return row
|
||||||
|
|
||||||
|
def test_an_answer_and_its_session(self):
|
||||||
|
answer, warning = assistant._conclude(
|
||||||
|
self.found(answer="done", session="abc"), 0, "", "", "Claude")
|
||||||
|
self.assertEqual(answer, "done")
|
||||||
|
self.assertEqual(warning, "")
|
||||||
|
self.assertEqual(assistant.read_session("claude", 1800), "abc")
|
||||||
|
|
||||||
|
def test_codex_stores_under_its_own_name(self):
|
||||||
|
assistant._conclude(self.found(answer="done", session="t-1"), 0, "",
|
||||||
|
"", "Codex")
|
||||||
|
self.assertEqual(assistant.read_session("codex", 1800), "t-1")
|
||||||
|
|
||||||
|
def test_a_non_zero_exit_with_nothing_to_show_for_it(self):
|
||||||
|
with self.assertRaises(assistant.AssistantError) as caught:
|
||||||
|
assistant._conclude(self.found(), 1, "it all went wrong\n", "", "Claude")
|
||||||
|
self.assertIn("it all went wrong", str(caught.exception))
|
||||||
|
|
||||||
|
def test_a_session_that_is_gone_is_raised_apart(self):
|
||||||
|
with self.assertRaises(assistant._SessionGone):
|
||||||
|
assistant._conclude(self.found(), 1, "session abc not found",
|
||||||
|
"abc", "Claude")
|
||||||
|
|
||||||
|
def test_a_session_that_is_gone_only_matters_when_one_was_resumed(self):
|
||||||
|
with self.assertRaises(assistant.AssistantError):
|
||||||
|
assistant._conclude(self.found(), 1, "session abc not found",
|
||||||
|
"", "Claude")
|
||||||
|
|
||||||
|
def test_an_answer_survives_a_non_zero_exit(self):
|
||||||
|
answer, _ = assistant._conclude(self.found(answer="done"), 1, "noise",
|
||||||
|
"", "Claude")
|
||||||
|
self.assertEqual(answer, "done")
|
||||||
|
|
||||||
|
def test_a_reported_failure_with_no_answer(self):
|
||||||
|
with self.assertRaises(assistant.AssistantError) as caught:
|
||||||
|
assistant._conclude(self.found(failure="the model refused"), 0, "",
|
||||||
|
"", "Claude")
|
||||||
|
self.assertIn("refused", str(caught.exception))
|
||||||
|
|
||||||
|
def test_a_run_that_said_nothing_at_all(self):
|
||||||
|
with self.assertRaises(assistant.AssistantError) as caught:
|
||||||
|
assistant._conclude(self.found(), 0, "", "", "Codex")
|
||||||
|
self.assertIn("Codex", str(caught.exception))
|
||||||
|
|
||||||
|
|
||||||
|
class AskClaude(DikteTest):
|
||||||
|
def run_ask(self, conf=None, events=None, code=0, stderr="", noise=(),
|
||||||
|
session=""):
|
||||||
|
conf = conf or self.config()
|
||||||
|
proc = FakeCli(events or [
|
||||||
|
{"type": "system", "subtype": "init", "session_id": "abc"},
|
||||||
|
{"type": "result", "session_id": "abc", "result": " done "},
|
||||||
|
], code=code, stderr=stderr, noise=noise)
|
||||||
|
stages = []
|
||||||
|
with only_these_tools("claude", "codex"), \
|
||||||
|
mock.patch.object(subprocess, "Popen", return_value=proc) as popen:
|
||||||
|
result = assistant._ask_claude(
|
||||||
|
"book it", conf, session, stages.append, None)
|
||||||
|
return result, popen.call_args.args[0], stages
|
||||||
|
|
||||||
|
def test_the_answer_comes_back_stripped(self):
|
||||||
|
(answer, warning), _, _ = self.run_ask()
|
||||||
|
self.assertEqual(answer, "done")
|
||||||
|
self.assertEqual(warning, "")
|
||||||
|
|
||||||
|
def test_the_prompt_goes_in_as_one_argument(self):
|
||||||
|
_, cmd, _ = self.run_ask()
|
||||||
|
self.assertEqual(cmd[:3], ["claude", "-p", "book it"])
|
||||||
|
|
||||||
|
def test_the_stream_is_asked_for_so_progress_can_be_shown(self):
|
||||||
|
_, cmd, _ = self.run_ask()
|
||||||
|
self.assertIn("--output-format", cmd)
|
||||||
|
self.assertIn("stream-json", cmd)
|
||||||
|
self.assertIn("--verbose", cmd)
|
||||||
|
|
||||||
|
def test_the_model_and_the_permission_mode_are_passed_on(self):
|
||||||
|
conf = self.config(assistant_model="opus",
|
||||||
|
assistant_permission_mode="plan")
|
||||||
|
_, cmd, _ = self.run_ask(conf)
|
||||||
|
self.assertEqual(cmd[cmd.index("--model") + 1], "opus")
|
||||||
|
self.assertEqual(cmd[cmd.index("--permission-mode") + 1], "plan")
|
||||||
|
|
||||||
|
def test_the_instruction_rides_along_as_a_system_prompt(self):
|
||||||
|
conf = self.config()
|
||||||
|
_, cmd, _ = self.run_ask(conf)
|
||||||
|
self.assertEqual(cmd[cmd.index("--append-system-prompt") + 1],
|
||||||
|
conf.assistant_prompt())
|
||||||
|
|
||||||
|
def test_no_effort_asked_for_means_no_flag(self):
|
||||||
|
_, cmd, _ = self.run_ask()
|
||||||
|
self.assertNotIn("--effort", cmd)
|
||||||
|
|
||||||
|
def test_an_effort_is_translated_to_the_provider_s_vocabulary(self):
|
||||||
|
_, cmd, _ = self.run_ask(self.config(assistant_reasoning="minimal"))
|
||||||
|
self.assertEqual(cmd[cmd.index("--effort") + 1], "low")
|
||||||
|
|
||||||
|
def test_a_conversation_is_resumed(self):
|
||||||
|
_, cmd, _ = self.run_ask(session="abc-123")
|
||||||
|
self.assertEqual(cmd[cmd.index("--resume") + 1], "abc-123")
|
||||||
|
|
||||||
|
def test_a_fresh_conversation_resumes_nothing(self):
|
||||||
|
_, cmd, _ = self.run_ask()
|
||||||
|
self.assertNotIn("--resume", cmd)
|
||||||
|
|
||||||
|
def test_every_tool_it_picks_up_is_named_in_the_corner(self):
|
||||||
|
_, _, stages = self.run_ask(events=[
|
||||||
|
{"type": "assistant", "message": {"content": [
|
||||||
|
{"type": "tool_use", "name": "WebSearch"}]}},
|
||||||
|
{"type": "assistant", "message": {"content": [
|
||||||
|
{"type": "tool_use", "name": "Bash"}]}},
|
||||||
|
{"type": "result", "result": "done"},
|
||||||
|
])
|
||||||
|
self.assertEqual(stages, ["Searching the web…", "Running a command…"])
|
||||||
|
|
||||||
|
def test_a_denied_tool_comes_back_as_a_warning_beside_the_answer(self):
|
||||||
|
(answer, warning), _, _ = self.run_ask(events=[
|
||||||
|
{"type": "result", "result": "I could not do that.",
|
||||||
|
"permission_denials": [{"tool_name": "Bash"}]},
|
||||||
|
])
|
||||||
|
self.assertEqual(answer, "I could not do that.")
|
||||||
|
self.assertIn("Bash", warning)
|
||||||
|
|
||||||
|
def test_a_run_that_ended_in_an_error(self):
|
||||||
|
with self.assertRaises(assistant.AssistantError):
|
||||||
|
self.run_ask(events=[{"type": "result", "is_error": True,
|
||||||
|
"result": "rate limited"}])
|
||||||
|
|
||||||
|
def test_the_odd_unstructured_line_among_the_json(self):
|
||||||
|
(answer, _), _, _ = self.run_ask(noise=["Loading…", "not json at all"])
|
||||||
|
self.assertEqual(answer, "done")
|
||||||
|
|
||||||
|
def test_a_json_line_that_is_not_an_object(self):
|
||||||
|
proc = FakeCli(code=0)
|
||||||
|
proc.stdout = io.StringIO('{"type": "result", "result": "done"}\n[1,2]\n')
|
||||||
|
with only_these_tools("claude"), \
|
||||||
|
mock.patch.object(subprocess, "Popen", return_value=proc):
|
||||||
|
answer, _ = assistant._ask_claude("hi", self.config(), "", None, None)
|
||||||
|
self.assertEqual(answer, "done")
|
||||||
|
|
||||||
|
|
||||||
|
class AskCodex(DikteTest):
|
||||||
|
def run_ask(self, conf=None, events=None, session=""):
|
||||||
|
conf = conf or self.config(assistant_provider="codex")
|
||||||
|
proc = FakeCli(events or [
|
||||||
|
{"type": "thread.started", "thread_id": "t-1"},
|
||||||
|
{"type": "item.completed",
|
||||||
|
"item": {"type": "agent_message", "text": "done"}},
|
||||||
|
])
|
||||||
|
stages = []
|
||||||
|
with only_these_tools("codex"), \
|
||||||
|
mock.patch.object(subprocess, "Popen", return_value=proc) as popen:
|
||||||
|
result = assistant._ask_codex("book it", conf, session,
|
||||||
|
stages.append, None)
|
||||||
|
return result, popen.call_args.args[0], stages
|
||||||
|
|
||||||
|
def test_the_answer(self):
|
||||||
|
(answer, _), _, _ = self.run_ask()
|
||||||
|
self.assertEqual(answer, "done")
|
||||||
|
|
||||||
|
def test_the_instruction_is_kept_apart_from_the_command(self):
|
||||||
|
"""Codex takes no system prompt, so the two must not read as one."""
|
||||||
|
conf = self.config(assistant_provider="codex")
|
||||||
|
_, cmd, _ = self.run_ask(conf)
|
||||||
|
body = cmd[-1]
|
||||||
|
self.assertTrue(body.startswith(conf.assistant_prompt()))
|
||||||
|
self.assertIn("\n\n---\n\n", body)
|
||||||
|
self.assertTrue(body.endswith("book it"))
|
||||||
|
|
||||||
|
def test_there_is_nobody_here_to_approve_anything(self):
|
||||||
|
_, cmd, _ = self.run_ask()
|
||||||
|
self.assertIn('approval_policy="never"', cmd)
|
||||||
|
self.assertIn("--skip-git-repo-check", cmd)
|
||||||
|
self.assertIn("--json", cmd)
|
||||||
|
|
||||||
|
def test_the_sandbox_setting_is_passed_on(self):
|
||||||
|
_, cmd, _ = self.run_ask(
|
||||||
|
self.config(assistant_provider="codex",
|
||||||
|
assistant_codex_sandbox="read-only"))
|
||||||
|
self.assertIn('sandbox_mode="read-only"', cmd)
|
||||||
|
|
||||||
|
def test_no_model_named_means_whatever_codex_is_set_to(self):
|
||||||
|
_, cmd, _ = self.run_ask()
|
||||||
|
self.assertNotIn("-m", cmd)
|
||||||
|
|
||||||
|
def test_a_model_of_your_own(self):
|
||||||
|
_, cmd, _ = self.run_ask(
|
||||||
|
self.config(assistant_provider="codex", assistant_codex_model=" gpt-5 "))
|
||||||
|
self.assertEqual(cmd[cmd.index("-m") + 1], "gpt-5")
|
||||||
|
|
||||||
|
def test_the_effort_lands_on_the_nearest_rung_codex_has(self):
|
||||||
|
_, cmd, _ = self.run_ask(
|
||||||
|
self.config(assistant_provider="codex", assistant_reasoning="max"))
|
||||||
|
self.assertIn('model_reasoning_effort="high"', cmd)
|
||||||
|
|
||||||
|
def test_a_conversation_is_resumed(self):
|
||||||
|
_, cmd, _ = self.run_ask(session="t-1")
|
||||||
|
self.assertEqual(cmd[:4], ["codex", "exec", "resume", "t-1"])
|
||||||
|
|
||||||
|
def test_a_fresh_conversation(self):
|
||||||
|
_, cmd, _ = self.run_ask()
|
||||||
|
self.assertEqual(cmd[:2], ["codex", "exec"])
|
||||||
|
|
||||||
|
def test_the_closing_message_is_the_answer(self):
|
||||||
|
(answer, _), _, _ = self.run_ask(events=[
|
||||||
|
{"type": "item.completed",
|
||||||
|
"item": {"type": "agent_message", "text": "let me look"}},
|
||||||
|
{"type": "item.completed",
|
||||||
|
"item": {"type": "agent_message", "text": "it is on Thursday"}},
|
||||||
|
])
|
||||||
|
self.assertEqual(answer, "it is on Thursday")
|
||||||
|
|
||||||
|
def test_the_work_is_narrated_as_it_goes(self):
|
||||||
|
_, _, stages = self.run_ask(events=[
|
||||||
|
{"type": "item.started", "item": {"type": "command_execution"}},
|
||||||
|
{"type": "item.completed",
|
||||||
|
"item": {"type": "agent_message", "text": "done"}},
|
||||||
|
])
|
||||||
|
self.assertEqual(stages, ["Running a command…"])
|
||||||
|
|
||||||
|
def test_a_turn_that_failed(self):
|
||||||
|
with self.assertRaises(assistant.AssistantError) as caught:
|
||||||
|
self.run_ask(events=[{"type": "turn.failed",
|
||||||
|
"error": {"message": "quota exhausted"}}])
|
||||||
|
self.assertIn("quota", str(caught.exception))
|
||||||
|
|
||||||
|
|
||||||
|
class AskOpenRouter(DikteTest):
|
||||||
|
def test_a_question_and_an_answer(self):
|
||||||
|
conf = self.config(assistant_provider="openrouter",
|
||||||
|
openrouter_api_key="sk-or-test")
|
||||||
|
with fake_urlopen({"choices": [{"message": {"content": "on Thursday"}}]}):
|
||||||
|
answer, warning = assistant.ask("when is it", conf)
|
||||||
|
self.assertEqual(answer, "on Thursday")
|
||||||
|
self.assertEqual(warning, "")
|
||||||
|
|
||||||
|
def test_the_conversation_is_ours_to_keep(self):
|
||||||
|
conf = self.config(assistant_provider="openrouter",
|
||||||
|
openrouter_api_key="sk-or-test")
|
||||||
|
with fake_urlopen({"choices": [{"message": {"content": "on Thursday"}}]}):
|
||||||
|
assistant.ask("when is it", conf)
|
||||||
|
stored = assistant.read_messages("openrouter", 1800)
|
||||||
|
self.assertEqual([row["content"] for row in stored],
|
||||||
|
["when is it", "on Thursday"])
|
||||||
|
|
||||||
|
def test_the_next_command_knows_what_that_means(self):
|
||||||
|
conf = self.config(assistant_provider="openrouter",
|
||||||
|
openrouter_api_key="sk-or-test")
|
||||||
|
assistant.write_session("openrouter", messages=[
|
||||||
|
{"role": "user", "content": "when is it"},
|
||||||
|
{"role": "assistant", "content": "on Thursday"}])
|
||||||
|
with fake_urlopen({"choices": [{"message": {"content": "moved"}}]}) as calls:
|
||||||
|
assistant.ask("move it to Friday", conf)
|
||||||
|
sent = json.loads(calls[0].data.decode("utf-8"))["messages"]
|
||||||
|
self.assertEqual(len(sent), 4) # system, the two stored, the new one
|
||||||
|
|
||||||
|
def test_an_api_failure_reads_as_an_assistant_failure(self):
|
||||||
|
conf = self.config(assistant_provider="openrouter")
|
||||||
|
with self.assertRaises(assistant.AssistantError):
|
||||||
|
assistant.ask("when is it", conf)
|
||||||
|
|
||||||
|
|
||||||
|
class Ask(DikteTest):
|
||||||
|
def test_a_cli_that_is_not_installed_says_where_to_change_it(self):
|
||||||
|
with only_these_tools(), \
|
||||||
|
self.assertRaises(assistant.AssistantError) as caught:
|
||||||
|
assistant.ask("hi", self.config())
|
||||||
|
self.assertIn("claude", str(caught.exception))
|
||||||
|
self.assertIn("Settings", str(caught.exception))
|
||||||
|
|
||||||
|
def test_a_session_that_is_gone_is_started_over_without_a_word(self):
|
||||||
|
conf = self.config()
|
||||||
|
assistant.write_session("claude", "stale-id")
|
||||||
|
attempts = []
|
||||||
|
|
||||||
|
def run(prompt, conf, session, on_stage, should_stop):
|
||||||
|
attempts.append(session)
|
||||||
|
if session:
|
||||||
|
raise assistant._SessionGone()
|
||||||
|
return "done", ""
|
||||||
|
|
||||||
|
with only_these_tools("claude"), \
|
||||||
|
mock.patch.object(assistant, "_ask_claude", side_effect=run):
|
||||||
|
answer, _ = assistant.ask("hi", conf)
|
||||||
|
self.assertEqual(answer, "done")
|
||||||
|
self.assertEqual(attempts, ["stale-id", ""])
|
||||||
|
self.assertEqual(assistant.stored_provider(), "")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
"""Level metering, the WAV writer, and what pactl is asked for.
|
||||||
|
|
||||||
|
The device list is where a platform port lands first, so the parsing is pinned
|
||||||
|
here: a source that is not a monitor is an input, one that is belongs to the
|
||||||
|
speakers, and neither list may go missing when pactl is absent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import array
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
import wave
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import audio
|
||||||
|
from tests.support import (
|
||||||
|
DikteTest,
|
||||||
|
FakeCompleted,
|
||||||
|
linux_only,
|
||||||
|
only_these_tools,
|
||||||
|
pcm,
|
||||||
|
silence,
|
||||||
|
stereo,
|
||||||
|
tone,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ChunkLevels(unittest.TestCase):
|
||||||
|
def test_silence(self):
|
||||||
|
self.assertEqual(audio.chunk_levels(silence(0.1)), (0.0, 0.0))
|
||||||
|
|
||||||
|
def test_nothing_at_all(self):
|
||||||
|
self.assertEqual(audio.chunk_levels(b""), (0.0, 0.0))
|
||||||
|
|
||||||
|
def test_half_a_sample_is_not_a_sample(self):
|
||||||
|
self.assertEqual(audio.chunk_levels(b"\x00"), (0.0, 0.0))
|
||||||
|
|
||||||
|
def test_an_odd_trailing_byte_is_ignored_rather_than_fatal(self):
|
||||||
|
peak, _ = audio.chunk_levels(pcm([16384, 16384]) + b"\x7f")
|
||||||
|
self.assertAlmostEqual(peak, 0.5, places=3)
|
||||||
|
|
||||||
|
def test_the_peak_is_the_loudest_sample_either_way(self):
|
||||||
|
peak, _ = audio.chunk_levels(pcm([0, 0, -32768, 100]))
|
||||||
|
self.assertEqual(peak, 1.0)
|
||||||
|
|
||||||
|
def test_the_rms_of_a_constant_signal_is_that_constant(self):
|
||||||
|
_, rms = audio.chunk_levels(pcm([16384] * 100))
|
||||||
|
self.assertAlmostEqual(rms, 0.5, places=3)
|
||||||
|
|
||||||
|
def test_the_rms_sits_below_the_peak_for_a_tone(self):
|
||||||
|
peak, rms = audio.chunk_levels(tone(0.1, amplitude=16384))
|
||||||
|
self.assertLess(rms, peak)
|
||||||
|
self.assertGreater(rms, 0.0)
|
||||||
|
|
||||||
|
def test_neither_number_ever_passes_one(self):
|
||||||
|
peak, rms = audio.chunk_levels(pcm([-32768] * 100))
|
||||||
|
self.assertEqual(peak, 1.0)
|
||||||
|
self.assertEqual(rms, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class StereoLevels(unittest.TestCase):
|
||||||
|
def test_the_channels_are_read_apart(self):
|
||||||
|
left, right = audio.stereo_levels(stereo(pcm([16384] * 50),
|
||||||
|
pcm([0] * 50)))
|
||||||
|
self.assertAlmostEqual(left, 0.5, places=3)
|
||||||
|
self.assertEqual(right, 0.0)
|
||||||
|
|
||||||
|
def test_nothing_at_all(self):
|
||||||
|
self.assertEqual(audio.stereo_levels(b""), (0.0, 0.0))
|
||||||
|
|
||||||
|
def test_a_partial_frame_is_ignored(self):
|
||||||
|
self.assertEqual(audio.stereo_levels(b"\x00\x01\x00"), (0.0, 0.0))
|
||||||
|
|
||||||
|
def test_a_meeting_with_both_sides_talking(self):
|
||||||
|
left, right = audio.stereo_levels(stereo(pcm([8192] * 50),
|
||||||
|
pcm([-16384] * 50)))
|
||||||
|
self.assertAlmostEqual(left, 0.25, places=3)
|
||||||
|
self.assertAlmostEqual(right, 0.5, places=3)
|
||||||
|
|
||||||
|
|
||||||
|
class WriteWav(DikteTest):
|
||||||
|
def test_the_header_says_what_the_recorder_captured(self):
|
||||||
|
path = audio.write_wav(silence(0.5))
|
||||||
|
self.addCleanup(os.unlink, path)
|
||||||
|
with contextlib.closing(wave.open(path, "rb")) as wav:
|
||||||
|
self.assertEqual(wav.getnchannels(), audio.CHANNELS)
|
||||||
|
self.assertEqual(wav.getsampwidth(), audio.SAMPLE_WIDTH)
|
||||||
|
self.assertEqual(wav.getframerate(), audio.RATE)
|
||||||
|
self.assertEqual(wav.getnframes(), int(audio.RATE * 0.5))
|
||||||
|
|
||||||
|
def test_the_samples_survive(self):
|
||||||
|
path = audio.write_wav(pcm([1000, -1000, 2000]))
|
||||||
|
self.addCleanup(os.unlink, path)
|
||||||
|
with contextlib.closing(wave.open(path, "rb")) as wav:
|
||||||
|
samples = array.array("h")
|
||||||
|
samples.frombytes(wav.readframes(3))
|
||||||
|
self.assertEqual(list(samples), [1000, -1000, 2000])
|
||||||
|
|
||||||
|
def test_a_meeting_is_written_at_two_channels(self):
|
||||||
|
path = audio.write_wav(stereo(silence(0.1), silence(0.1)), channels=2)
|
||||||
|
self.addCleanup(os.unlink, path)
|
||||||
|
with contextlib.closing(wave.open(path, "rb")) as wav:
|
||||||
|
self.assertEqual(wav.getnchannels(), 2)
|
||||||
|
|
||||||
|
|
||||||
|
SOURCES = [
|
||||||
|
{"name": "alsa_input.pci-0000_00_1f.3.analog-stereo",
|
||||||
|
"description": "Built-in Audio Analog Stereo"},
|
||||||
|
{"name": "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor",
|
||||||
|
"description": "Monitor of Built-in Audio"},
|
||||||
|
{"name": "bluez_input.AA_BB.headset", "description": ""},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@linux_only
|
||||||
|
class Devices(DikteTest):
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def pactl(self, sources=None, sink=None, tools=("pactl",)):
|
||||||
|
payloads = {
|
||||||
|
"list": FakeCompleted(stdout=json.dumps(
|
||||||
|
SOURCES if sources is None else sources)),
|
||||||
|
"get-default-sink": FakeCompleted(stdout=(sink or "") + "\n"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def run(cmd, **kwargs):
|
||||||
|
return payloads["get-default-sink" if "get-default-sink" in cmd
|
||||||
|
else "list"]
|
||||||
|
|
||||||
|
with only_these_tools(*tools), \
|
||||||
|
mock.patch.object(subprocess, "run", side_effect=run):
|
||||||
|
yield
|
||||||
|
|
||||||
|
def test_no_pactl_installed(self):
|
||||||
|
with only_these_tools():
|
||||||
|
self.assertEqual(audio.list_sources(), [])
|
||||||
|
self.assertEqual(audio.list_monitors(), [])
|
||||||
|
self.assertEqual(audio.default_monitor(), "")
|
||||||
|
|
||||||
|
def test_inputs_leave_the_monitors_out(self):
|
||||||
|
with self.pactl():
|
||||||
|
names = [name for name, _ in audio.list_sources()]
|
||||||
|
self.assertEqual(names, [SOURCES[0]["name"], SOURCES[2]["name"]])
|
||||||
|
|
||||||
|
def test_monitors_are_the_other_half(self):
|
||||||
|
with self.pactl():
|
||||||
|
self.assertEqual([name for name, _ in audio.list_monitors()],
|
||||||
|
[SOURCES[1]["name"]])
|
||||||
|
|
||||||
|
def test_a_device_with_no_description_is_shown_by_its_name(self):
|
||||||
|
with self.pactl():
|
||||||
|
sources = dict(audio.list_sources())
|
||||||
|
self.assertEqual(sources[SOURCES[2]["name"]], SOURCES[2]["name"])
|
||||||
|
|
||||||
|
def test_pactl_output_that_is_not_json(self):
|
||||||
|
with only_these_tools("pactl"), \
|
||||||
|
mock.patch.object(subprocess, "run",
|
||||||
|
return_value=FakeCompleted(stdout="not json")):
|
||||||
|
self.assertEqual(audio.list_sources(), [])
|
||||||
|
|
||||||
|
def test_pactl_that_will_not_run(self):
|
||||||
|
with only_these_tools("pactl"), \
|
||||||
|
mock.patch.object(subprocess, "run", side_effect=OSError("nope")):
|
||||||
|
self.assertEqual(audio.list_sources(), [])
|
||||||
|
|
||||||
|
def test_pactl_that_exits_non_zero(self):
|
||||||
|
with only_these_tools("pactl"), \
|
||||||
|
mock.patch.object(subprocess, "run",
|
||||||
|
side_effect=subprocess.CalledProcessError(1, "pactl")):
|
||||||
|
self.assertEqual(audio.list_sources(), [])
|
||||||
|
|
||||||
|
def test_the_default_output_is_found_by_its_monitor(self):
|
||||||
|
with self.pactl(sink="alsa_output.pci-0000_00_1f.3.analog-stereo"):
|
||||||
|
self.assertEqual(audio.default_monitor(),
|
||||||
|
SOURCES[1]["name"])
|
||||||
|
|
||||||
|
def test_a_default_sink_with_no_monitor_of_its_own(self):
|
||||||
|
with self.pactl(sink="alsa_output.usb-something"):
|
||||||
|
self.assertEqual(audio.default_monitor(), "")
|
||||||
|
|
||||||
|
def test_no_default_sink_at_all(self):
|
||||||
|
with self.pactl(sink=""):
|
||||||
|
self.assertEqual(audio.default_monitor(), "")
|
||||||
|
|
||||||
|
def test_a_monitor_is_trusted_when_the_list_is_empty(self):
|
||||||
|
"""pactl answered about the sink but not about the sources."""
|
||||||
|
with self.pactl(sources=[], sink="alsa_output.usb-something"):
|
||||||
|
self.assertEqual(audio.default_monitor(),
|
||||||
|
"alsa_output.usb-something.monitor")
|
||||||
|
|
||||||
|
|
||||||
|
class FakeProcess:
|
||||||
|
"""A pw-record that hands over a fixed buffer and then ends."""
|
||||||
|
|
||||||
|
def __init__(self, data):
|
||||||
|
import io
|
||||||
|
self.stdout = io.BytesIO(data)
|
||||||
|
self.stderr = io.BytesIO(b"")
|
||||||
|
self.signals = []
|
||||||
|
self._alive = True
|
||||||
|
|
||||||
|
def poll(self):
|
||||||
|
return None if self._alive else 0
|
||||||
|
|
||||||
|
def send_signal(self, sig):
|
||||||
|
self.signals.append(sig)
|
||||||
|
self._alive = False
|
||||||
|
|
||||||
|
def wait(self, timeout=None):
|
||||||
|
self._alive = False
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def kill(self):
|
||||||
|
self._alive = False
|
||||||
|
|
||||||
|
|
||||||
|
@linux_only
|
||||||
|
class RecorderChain(DikteTest):
|
||||||
|
"""Start to WAV, with pw-record faked out."""
|
||||||
|
|
||||||
|
def record(self, data, target="", max_seconds=300):
|
||||||
|
recorder = audio.Recorder()
|
||||||
|
results = []
|
||||||
|
failures = []
|
||||||
|
recorder.stopped.connect(lambda *args: results.append(args))
|
||||||
|
recorder.failed.connect(failures.append)
|
||||||
|
proc = FakeProcess(data)
|
||||||
|
with only_these_tools("pw-record"), \
|
||||||
|
mock.patch.object(subprocess, "Popen", return_value=proc) as popen:
|
||||||
|
recorder.start(target=target, max_seconds=max_seconds)
|
||||||
|
recorder._thread.join(timeout=5)
|
||||||
|
recorder.stop()
|
||||||
|
return recorder, results, failures, popen
|
||||||
|
|
||||||
|
def test_pw_record_is_not_installed(self):
|
||||||
|
recorder = audio.Recorder()
|
||||||
|
failures = []
|
||||||
|
recorder.failed.connect(failures.append)
|
||||||
|
with only_these_tools():
|
||||||
|
recorder.start()
|
||||||
|
self.assertEqual(len(failures), 1)
|
||||||
|
self.assertIn("pipewire", failures[0])
|
||||||
|
|
||||||
|
def test_the_capture_format_is_what_the_rest_of_the_code_expects(self):
|
||||||
|
_, _, _, popen = self.record(silence(1.0))
|
||||||
|
cmd = popen.call_args.args[0]
|
||||||
|
self.assertEqual(cmd[0], "pw-record")
|
||||||
|
self.assertIn(f"--rate={audio.RATE}", cmd)
|
||||||
|
self.assertIn(f"--channels={audio.CHANNELS}", cmd)
|
||||||
|
self.assertIn("--format=s16", cmd)
|
||||||
|
self.assertEqual(cmd[-1], "-")
|
||||||
|
|
||||||
|
def test_no_target_means_no_target_flag(self):
|
||||||
|
_, _, _, popen = self.record(silence(0.5))
|
||||||
|
self.assertFalse([arg for arg in popen.call_args.args[0]
|
||||||
|
if arg.startswith("--target=")])
|
||||||
|
|
||||||
|
def test_a_chosen_microphone_is_passed_on(self):
|
||||||
|
_, _, _, popen = self.record(silence(0.5), target="alsa_input.usb")
|
||||||
|
self.assertIn("--target=alsa_input.usb", popen.call_args.args[0])
|
||||||
|
|
||||||
|
def test_a_recording_ends_as_a_wav_with_its_duration_and_levels(self):
|
||||||
|
_, results, failures, _ = self.record(tone(1.0))
|
||||||
|
self.assertEqual(failures, [])
|
||||||
|
path, duration, rms = results[0]
|
||||||
|
self.addCleanup(os.unlink, path)
|
||||||
|
self.assertAlmostEqual(duration, 1.0, places=2)
|
||||||
|
self.assertTrue(rms)
|
||||||
|
self.assertGreater(max(rms), 0.0)
|
||||||
|
with contextlib.closing(wave.open(path, "rb")) as wav:
|
||||||
|
self.assertEqual(wav.getnframes(), audio.RATE)
|
||||||
|
|
||||||
|
def test_a_stray_keypress_is_not_a_recording(self):
|
||||||
|
_, results, failures, _ = self.record(silence(0.1))
|
||||||
|
self.assertEqual(results, [])
|
||||||
|
self.assertIn("0.3", failures[0])
|
||||||
|
|
||||||
|
def test_a_cancelled_recording_produces_nothing(self):
|
||||||
|
recorder = audio.Recorder()
|
||||||
|
results = []
|
||||||
|
recorder.stopped.connect(lambda *args: results.append(args))
|
||||||
|
proc = FakeProcess(tone(1.0))
|
||||||
|
with only_these_tools("pw-record"), \
|
||||||
|
mock.patch.object(subprocess, "Popen", return_value=proc):
|
||||||
|
recorder.start()
|
||||||
|
recorder._thread.join(timeout=5)
|
||||||
|
recorder.cancel()
|
||||||
|
recorder.stop()
|
||||||
|
self.assertEqual(results, [])
|
||||||
|
|
||||||
|
def test_a_recording_that_runs_past_the_limit_is_cut_off(self):
|
||||||
|
_, results, _, _ = self.record(tone(3.0), max_seconds=1)
|
||||||
|
path, duration, _ = results[0]
|
||||||
|
self.addCleanup(os.unlink, path)
|
||||||
|
self.assertLessEqual(duration, 1.1)
|
||||||
|
|
||||||
|
def test_a_recorder_that_could_not_start(self):
|
||||||
|
recorder = audio.Recorder()
|
||||||
|
failures = []
|
||||||
|
recorder.failed.connect(failures.append)
|
||||||
|
with only_these_tools("pw-record"), \
|
||||||
|
mock.patch.object(subprocess, "Popen", side_effect=OSError("nope")):
|
||||||
|
recorder.start()
|
||||||
|
self.assertEqual(len(failures), 1)
|
||||||
|
self.assertFalse(recorder.active)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
"""The terminal interface, which is the part a script depends on.
|
||||||
|
|
||||||
|
Output is a contract as much as an interface: --json prints one object on
|
||||||
|
stdout, progress goes to stderr so it never lands in a pipe, and the exit code
|
||||||
|
says which of the four things happened. Nothing here starts an instance; the
|
||||||
|
socket is faked, and everything that runs locally runs for real.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import cli
|
||||||
|
import config as cfg
|
||||||
|
import ipc
|
||||||
|
from tests.support import DikteTest
|
||||||
|
|
||||||
|
|
||||||
|
class Options:
|
||||||
|
"""The parsed command line, as much of it as the printers read."""
|
||||||
|
|
||||||
|
def __init__(self, **values):
|
||||||
|
self.json = False
|
||||||
|
self.quiet = False
|
||||||
|
for key, value in values.items():
|
||||||
|
setattr(self, key, value)
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def captured():
|
||||||
|
out, err = io.StringIO(), io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
||||||
|
yield out, err
|
||||||
|
|
||||||
|
|
||||||
|
class Printing(unittest.TestCase):
|
||||||
|
def test_plain_output_is_the_thing_a_person_wanted(self):
|
||||||
|
with captured() as (out, err):
|
||||||
|
code = cli.out(Options(), {"ok": True, "text": "hello"}, "hello")
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
self.assertEqual(out.getvalue().strip(), "hello")
|
||||||
|
self.assertEqual(err.getvalue(), "")
|
||||||
|
|
||||||
|
def test_json_output_is_one_object(self):
|
||||||
|
with captured() as (out, _):
|
||||||
|
cli.out(Options(json=True), {"ok": True, "text": "hello"}, "hello")
|
||||||
|
self.assertEqual(json.loads(out.getvalue()), {"ok": True, "text": "hello"})
|
||||||
|
|
||||||
|
def test_nothing_to_say_prints_nothing(self):
|
||||||
|
with captured() as (out, _):
|
||||||
|
cli.out(Options(), {"ok": True})
|
||||||
|
self.assertEqual(out.getvalue(), "")
|
||||||
|
|
||||||
|
def test_progress_never_reaches_stdout(self):
|
||||||
|
with captured() as (out, err):
|
||||||
|
cli.note(Options(), "Transcribing…")
|
||||||
|
self.assertEqual(out.getvalue(), "")
|
||||||
|
self.assertIn("Transcribing…", err.getvalue())
|
||||||
|
|
||||||
|
def test_quiet_keeps_progress_off_stderr_too(self):
|
||||||
|
with captured() as (_, err):
|
||||||
|
cli.note(Options(quiet=True), "Transcribing…")
|
||||||
|
self.assertEqual(err.getvalue(), "")
|
||||||
|
|
||||||
|
def test_a_failure_goes_to_stderr_and_returns_one(self):
|
||||||
|
with captured() as (out, err):
|
||||||
|
code = cli.fail(Options(), "no microphone")
|
||||||
|
self.assertEqual(code, 1)
|
||||||
|
self.assertEqual(out.getvalue(), "")
|
||||||
|
self.assertIn("no microphone", err.getvalue())
|
||||||
|
|
||||||
|
def test_a_failure_as_json_stays_on_stdout(self):
|
||||||
|
with captured() as (out, err):
|
||||||
|
code = cli.fail(Options(json=True), "no microphone", 3, running=False)
|
||||||
|
self.assertEqual(code, 3)
|
||||||
|
self.assertEqual(json.loads(out.getvalue()),
|
||||||
|
{"ok": False, "error": "no microphone", "running": False})
|
||||||
|
self.assertEqual(err.getvalue(), "")
|
||||||
|
|
||||||
|
|
||||||
|
class Coerce(unittest.TestCase):
|
||||||
|
"""A value off the command line, in the type the setting is stored as."""
|
||||||
|
|
||||||
|
def test_a_string_stays_a_string(self):
|
||||||
|
self.assertEqual(cli._coerce("cleanup_model", "some/model"), "some/model")
|
||||||
|
|
||||||
|
def test_the_words_that_mean_true(self):
|
||||||
|
for raw in ("1", "true", "TRUE", "yes", "on", " True "):
|
||||||
|
with self.subTest(raw=raw):
|
||||||
|
self.assertIs(cli._coerce("cleanup_enabled", raw), True)
|
||||||
|
|
||||||
|
def test_the_words_that_mean_false(self):
|
||||||
|
for raw in ("0", "false", "no", "off"):
|
||||||
|
with self.subTest(raw=raw):
|
||||||
|
self.assertIs(cli._coerce("cleanup_enabled", raw), False)
|
||||||
|
|
||||||
|
def test_anything_else_is_not_a_boolean(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
cli._coerce("cleanup_enabled", "maybe")
|
||||||
|
|
||||||
|
def test_a_whole_number(self):
|
||||||
|
self.assertEqual(cli._coerce("history_limit", "50"), 50)
|
||||||
|
self.assertEqual(cli._coerce("history_limit", "50.9"), 50)
|
||||||
|
|
||||||
|
def test_a_number_with_a_fraction(self):
|
||||||
|
self.assertEqual(cli._coerce("silence_db", "-42.5"), -42.5)
|
||||||
|
|
||||||
|
def test_something_that_is_not_a_number(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
cli._coerce("history_limit", "lots")
|
||||||
|
|
||||||
|
def test_a_boolean_is_settled_before_it_is_read_as_a_number(self):
|
||||||
|
"""bool is a subclass of int, so the order of the checks matters."""
|
||||||
|
self.assertIs(cli._coerce("cleanup_enabled", "1"), True)
|
||||||
|
|
||||||
|
|
||||||
|
class Masking(unittest.TestCase):
|
||||||
|
def test_a_key_is_shown_by_its_last_four(self):
|
||||||
|
self.assertEqual(cli._mask("openai_api_key", "sk-abcdefgh1234"), "…1234")
|
||||||
|
|
||||||
|
def test_an_empty_key_is_not_masked_into_something(self):
|
||||||
|
self.assertEqual(cli._mask("openai_api_key", ""), "")
|
||||||
|
|
||||||
|
def test_anything_that_is_not_a_key_is_shown(self):
|
||||||
|
self.assertEqual(cli._mask("cleanup_model", "some/model"), "some/model")
|
||||||
|
|
||||||
|
def test_both_keys_are_covered(self):
|
||||||
|
for key in cli.SECRET_KEYS:
|
||||||
|
with self.subTest(key=key):
|
||||||
|
self.assertTrue(cli._mask(key, "sk-secret").startswith("…"))
|
||||||
|
|
||||||
|
|
||||||
|
class Parser(unittest.TestCase):
|
||||||
|
"""Every verb has to parse, and keep the flag that was typed before it."""
|
||||||
|
|
||||||
|
def parse(self, *argv):
|
||||||
|
return cli.build_parser().parse_args(list(argv))
|
||||||
|
|
||||||
|
def test_no_verb_at_all_is_the_settings_window(self):
|
||||||
|
# argparse leaves the dest as None; run() is what turns it into "".
|
||||||
|
opts = self.parse()
|
||||||
|
self.assertIsNone(opts.verb)
|
||||||
|
self.assertEqual(opts.func, cli.cmd_plain)
|
||||||
|
|
||||||
|
def test_every_verb_is_wired_to_something(self):
|
||||||
|
for verb in ("record", "toggle", "start", "stop", "cancel", "ask",
|
||||||
|
"session", "transcribe", "meeting", "meetings", "history",
|
||||||
|
"config", "prompt", "devices", "models", "test-key",
|
||||||
|
"doctor", "shortcut", "status", "settings", "restart",
|
||||||
|
"quit", "help"):
|
||||||
|
with self.subTest(verb=verb):
|
||||||
|
argv = [verb]
|
||||||
|
if verb == "transcribe":
|
||||||
|
argv.append("clip.mp3")
|
||||||
|
opts = self.parse(*argv)
|
||||||
|
self.assertEqual(opts.verb, verb)
|
||||||
|
self.assertTrue(callable(opts.func))
|
||||||
|
|
||||||
|
def test_the_old_spellings_still_parse(self):
|
||||||
|
for verb in ("ask-cancel", "ask-reset", "meeting-cancel"):
|
||||||
|
with self.subTest(verb=verb):
|
||||||
|
self.assertEqual(self.parse(verb).verb, verb)
|
||||||
|
|
||||||
|
def test_a_flag_typed_before_the_verb_survives(self):
|
||||||
|
self.assertTrue(self.parse("--json", "status").json)
|
||||||
|
|
||||||
|
def test_a_flag_typed_after_the_verb_works_too(self):
|
||||||
|
self.assertTrue(self.parse("status", "--json").json)
|
||||||
|
|
||||||
|
def test_a_flag_before_the_verb_is_not_overwritten_by_the_default(self):
|
||||||
|
opts = self.parse("--quiet", "record")
|
||||||
|
self.assertTrue(opts.quiet)
|
||||||
|
|
||||||
|
def test_a_command_to_the_agent_is_taken_as_written(self):
|
||||||
|
opts = self.parse("ask", "book", "it", "for", "Thursday")
|
||||||
|
self.assertEqual(opts.text, ["book", "it", "for", "Thursday"])
|
||||||
|
|
||||||
|
def test_a_command_with_no_text_is_a_recording(self):
|
||||||
|
self.assertEqual(self.parse("ask").text, [])
|
||||||
|
|
||||||
|
def test_the_subcommands_of_a_group(self):
|
||||||
|
self.assertEqual(self.parse("config", "get", "cleanup_model").key,
|
||||||
|
"cleanup_model")
|
||||||
|
self.assertEqual(self.parse("history", "list", "--limit", "5").limit, 5)
|
||||||
|
self.assertEqual(self.parse("meetings", "show", "3").which, "3")
|
||||||
|
|
||||||
|
def test_a_group_with_no_subcommand_asks_for_one(self):
|
||||||
|
with captured():
|
||||||
|
self.assertEqual(self.parse("config").func(Options()), 2)
|
||||||
|
|
||||||
|
def test_the_three_way_flags_start_out_undecided(self):
|
||||||
|
"""--cleanup and --no-cleanup both given as nothing means the setting."""
|
||||||
|
opts = self.parse("transcribe", "clip.mp3")
|
||||||
|
self.assertIsNone(opts.cleanup)
|
||||||
|
self.assertIsNone(opts.timestamps)
|
||||||
|
self.assertFalse(self.parse("transcribe", "clip.mp3", "--no-cleanup").cleanup)
|
||||||
|
self.assertTrue(self.parse("transcribe", "clip.mp3", "--cleanup").cleanup)
|
||||||
|
|
||||||
|
def test_a_setting_that_is_not_a_choice_is_refused(self):
|
||||||
|
with self.assertRaises(SystemExit), captured():
|
||||||
|
self.parse("models", "--provider", "ollama")
|
||||||
|
|
||||||
|
def test_a_flag_that_falls_back_to_the_setting(self):
|
||||||
|
self.assertIs(cli._pick(None, True), True)
|
||||||
|
self.assertIs(cli._pick(False, True), False)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigCommands(DikteTest):
|
||||||
|
def run_cmd(self, func, **values):
|
||||||
|
with captured() as (out, err):
|
||||||
|
code = func(Options(**values))
|
||||||
|
return code, out.getvalue(), err.getvalue()
|
||||||
|
|
||||||
|
def test_reading_a_setting(self):
|
||||||
|
code, out, _ = self.run_cmd(cli.cmd_config_get, key="cleanup_model")
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
self.assertEqual(out.strip(), cfg.DEFAULTS["cleanup_model"])
|
||||||
|
|
||||||
|
def test_reading_a_setting_that_is_not_a_string(self):
|
||||||
|
_, out, _ = self.run_cmd(cli.cmd_config_get, key="history_limit")
|
||||||
|
self.assertEqual(out.strip(), str(cfg.DEFAULTS["history_limit"]))
|
||||||
|
|
||||||
|
def test_a_setting_nobody_has(self):
|
||||||
|
code, _, err = self.run_cmd(cli.cmd_config_get, key="no_such_setting")
|
||||||
|
self.assertEqual(code, 2)
|
||||||
|
self.assertIn("unknown setting", err)
|
||||||
|
|
||||||
|
def test_writing_a_setting_reaches_the_file(self):
|
||||||
|
with mock.patch.object(ipc, "send"):
|
||||||
|
code, _, _ = self.run_cmd(cli.cmd_config_set, key="cleanup_model",
|
||||||
|
value="some/model")
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
self.assertEqual(cfg.Config()["cleanup_model"], "some/model")
|
||||||
|
|
||||||
|
def test_a_running_instance_is_told_to_read_it_back(self):
|
||||||
|
"""It would otherwise write its own copy back over the change."""
|
||||||
|
with mock.patch.object(ipc, "send") as send:
|
||||||
|
self.run_cmd(cli.cmd_config_set, key="cleanup_model", value="some/model")
|
||||||
|
send.assert_called_once_with("reload")
|
||||||
|
|
||||||
|
def test_writing_a_boolean(self):
|
||||||
|
with mock.patch.object(ipc, "send"):
|
||||||
|
self.run_cmd(cli.cmd_config_set, key="cleanup_enabled", value="off")
|
||||||
|
self.assertIs(cfg.Config()["cleanup_enabled"], False)
|
||||||
|
|
||||||
|
def test_a_value_of_the_wrong_type(self):
|
||||||
|
with mock.patch.object(ipc, "send"):
|
||||||
|
code, _, err = self.run_cmd(cli.cmd_config_set,
|
||||||
|
key="history_limit", value="lots")
|
||||||
|
self.assertEqual(code, 2)
|
||||||
|
# A number says only what could not be converted; a boolean names the
|
||||||
|
# setting as well, because "true or false" needs the context.
|
||||||
|
self.assertIn("lots", err)
|
||||||
|
|
||||||
|
def test_a_key_is_masked_when_it_is_written_back(self):
|
||||||
|
with mock.patch.object(ipc, "send"):
|
||||||
|
_, out, _ = self.run_cmd(cli.cmd_config_set, key="openai_api_key",
|
||||||
|
value="sk-abcdefgh1234")
|
||||||
|
self.assertNotIn("sk-abcdefgh", out)
|
||||||
|
self.assertIn("1234", out)
|
||||||
|
|
||||||
|
def test_the_listing_masks_the_keys(self):
|
||||||
|
with mock.patch.object(ipc, "send"):
|
||||||
|
self.run_cmd(cli.cmd_config_set, key="openai_api_key",
|
||||||
|
value="sk-abcdefgh1234")
|
||||||
|
_, out, _ = self.run_cmd(cli.cmd_config_list, reveal=False)
|
||||||
|
self.assertNotIn("sk-abcdefgh", out)
|
||||||
|
|
||||||
|
def test_a_key_belongs_to_whoever_asked_for_it_by_name(self):
|
||||||
|
with mock.patch.object(ipc, "send"):
|
||||||
|
self.run_cmd(cli.cmd_config_set, key="openai_api_key",
|
||||||
|
value="sk-abcdefgh1234")
|
||||||
|
_, out, _ = self.run_cmd(cli.cmd_config_list, reveal=True)
|
||||||
|
self.assertIn("sk-abcdefgh1234", out)
|
||||||
|
|
||||||
|
def test_the_listing_shortens_a_long_value(self):
|
||||||
|
with mock.patch.object(ipc, "send"):
|
||||||
|
self.run_cmd(cli.cmd_config_set, key="cleanup_prompt", value="x" * 200)
|
||||||
|
_, out, _ = self.run_cmd(cli.cmd_config_list, reveal=False)
|
||||||
|
self.assertNotIn("x" * 100, out)
|
||||||
|
|
||||||
|
def test_resetting_one_setting(self):
|
||||||
|
with mock.patch.object(ipc, "send"):
|
||||||
|
self.run_cmd(cli.cmd_config_set, key="cleanup_model", value="some/model")
|
||||||
|
code, _, _ = self.run_cmd(cli.cmd_config_reset, key=["cleanup_model"],
|
||||||
|
all=False)
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
self.assertEqual(cfg.Config()["cleanup_model"], cfg.DEFAULTS["cleanup_model"])
|
||||||
|
|
||||||
|
def test_resetting_nothing_asks_what_to_reset(self):
|
||||||
|
code, _, err = self.run_cmd(cli.cmd_config_reset, key=[], all=False)
|
||||||
|
self.assertEqual(code, 2)
|
||||||
|
self.assertIn("--all", err)
|
||||||
|
|
||||||
|
def test_resetting_everything(self):
|
||||||
|
with mock.patch.object(ipc, "send"):
|
||||||
|
self.run_cmd(cli.cmd_config_set, key="cleanup_model", value="some/model")
|
||||||
|
self.run_cmd(cli.cmd_config_reset, key=[], all=True)
|
||||||
|
self.assertEqual(cfg.Config()["cleanup_model"], cfg.DEFAULTS["cleanup_model"])
|
||||||
|
|
||||||
|
def test_where_things_are_stored(self):
|
||||||
|
_, out, _ = self.run_cmd(cli.cmd_config_path, json=True)
|
||||||
|
paths = json.loads(out)
|
||||||
|
self.assertEqual(paths["config"], str(cfg.CONFIG_FILE))
|
||||||
|
self.assertEqual(paths["history"], str(cfg.HISTORY_FILE))
|
||||||
|
|
||||||
|
def test_the_prompt_a_run_would_really_send(self):
|
||||||
|
_, out, _ = self.run_cmd(cli.cmd_prompt, which="cleanup")
|
||||||
|
self.assertEqual(out.strip(), cfg.CLEANUP_PROMPT_EN.strip())
|
||||||
|
|
||||||
|
def test_all_four_prompts_at_once(self):
|
||||||
|
_, out, _ = self.run_cmd(cli.cmd_prompt, which=None, json=True)
|
||||||
|
self.assertEqual(set(json.loads(out)["prompts"]),
|
||||||
|
{"cleanup", "subtitles", "meeting", "agent"})
|
||||||
|
|
||||||
|
|
||||||
|
class Finding(DikteTest):
|
||||||
|
def test_no_history_at_all(self):
|
||||||
|
self.assertIsNone(cli._find_history("last"))
|
||||||
|
|
||||||
|
def test_the_newest_entry(self):
|
||||||
|
for text in ("first", "second"):
|
||||||
|
cfg.append_history({"ts": "now", "text": text})
|
||||||
|
self.assertEqual(cli._find_history("last")["text"], "second")
|
||||||
|
self.assertEqual(cli._find_history("1")["text"], "second")
|
||||||
|
self.assertEqual(cli._find_history("2")["text"], "first")
|
||||||
|
|
||||||
|
def test_counting_past_the_end(self):
|
||||||
|
cfg.append_history({"ts": "now", "text": "only one"})
|
||||||
|
self.assertIsNone(cli._find_history("2"))
|
||||||
|
self.assertIsNone(cli._find_history("0"))
|
||||||
|
|
||||||
|
def test_something_that_is_not_a_number(self):
|
||||||
|
cfg.append_history({"ts": "now", "text": "only one"})
|
||||||
|
self.assertIsNone(cli._find_history("yesterday"))
|
||||||
|
|
||||||
|
def test_a_meeting_by_its_stem(self):
|
||||||
|
for base in ("20260801-100000", "20260802-110000"):
|
||||||
|
cfg.save_meeting({"base": base, "status": "done"})
|
||||||
|
self.assertEqual(cli._find_meeting("20260801-100000")["base"],
|
||||||
|
"20260801-100000")
|
||||||
|
|
||||||
|
def test_a_meeting_by_the_start_of_its_stem(self):
|
||||||
|
for base in ("20260801-100000", "20260801-110000"):
|
||||||
|
cfg.save_meeting({"base": base, "status": "done"})
|
||||||
|
self.assertEqual(cli._find_meeting("20260801-1")["base"], "20260801-110000")
|
||||||
|
|
||||||
|
def test_a_meeting_by_the_date_it_was_recorded(self):
|
||||||
|
"""A stem is all digits too, so a date must not be read as a position."""
|
||||||
|
for base in ("20260801-100000", "20260801-140000", "20260802-110000"):
|
||||||
|
cfg.save_meeting({"base": base, "status": "done"})
|
||||||
|
self.assertEqual(cli._find_meeting("20260801")["base"], "20260801-140000")
|
||||||
|
|
||||||
|
def test_a_meeting_by_position(self):
|
||||||
|
for base in ("20260801-100000", "20260802-110000"):
|
||||||
|
cfg.save_meeting({"base": base, "status": "done"})
|
||||||
|
self.assertEqual(cli._find_meeting("1")["base"], "20260802-110000")
|
||||||
|
self.assertEqual(cli._find_meeting("2")["base"], "20260801-100000")
|
||||||
|
self.assertEqual(cli._find_meeting("last")["base"], "20260802-110000")
|
||||||
|
|
||||||
|
def test_a_position_wins_while_there_are_that_many_meetings(self):
|
||||||
|
"""Counting back is what a small number has always meant, and a stem
|
||||||
|
never starts with one: it starts with the year."""
|
||||||
|
for base in ("20260801-100000", "20260802-110000"):
|
||||||
|
cfg.save_meeting({"base": base, "status": "done"})
|
||||||
|
self.assertEqual(cli._find_meeting("2")["base"], "20260801-100000")
|
||||||
|
|
||||||
|
def test_counting_past_the_end_finds_nothing_rather_than_the_wrong_one(self):
|
||||||
|
cfg.save_meeting({"base": "20260801-100000", "status": "done"})
|
||||||
|
self.assertIsNone(cli._find_meeting("9"))
|
||||||
|
self.assertIsNone(cli._find_meeting("0"))
|
||||||
|
|
||||||
|
def test_a_meeting_nobody_recorded(self):
|
||||||
|
cfg.save_meeting({"base": "20260801-100000", "status": "done"})
|
||||||
|
self.assertIsNone(cli._find_meeting("20261231"))
|
||||||
|
|
||||||
|
|
||||||
|
class WithoutAnInstance(DikteTest):
|
||||||
|
"""Nothing is listening on the socket, which is three different things.
|
||||||
|
|
||||||
|
A verb that can start the application does; one that asks for a state the
|
||||||
|
application is already in succeeds; anything else fails with code 3.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def run_verb(self, argv):
|
||||||
|
# launch_gui replaces this process with the application, so it never
|
||||||
|
# comes back in real use and must not be allowed to here.
|
||||||
|
with mock.patch.object(ipc, "send", return_value=None), \
|
||||||
|
mock.patch.object(cli, "launch_gui") as launch, \
|
||||||
|
captured() as (out, err):
|
||||||
|
code = cli.run(argv)
|
||||||
|
return code, out.getvalue(), err.getvalue(), launch
|
||||||
|
|
||||||
|
def test_pressing_the_key_on_a_fresh_login_starts_it_recording(self):
|
||||||
|
"""What the KDE shortcut has always relied on."""
|
||||||
|
_, _, _, launch = self.run_verb(["toggle"])
|
||||||
|
launch.assert_called_once_with("toggle")
|
||||||
|
|
||||||
|
def test_every_verb_that_opens_a_window_can_start_it(self):
|
||||||
|
for verb in ("settings", "toggle", "ask", "meeting"):
|
||||||
|
with self.subTest(verb=verb):
|
||||||
|
self.assertTrue(self.run_verb([verb])[3].called)
|
||||||
|
|
||||||
|
def test_a_verb_asked_to_wait_starts_nothing(self):
|
||||||
|
"""There would be no run to wait for; the process would just be replaced."""
|
||||||
|
_, _, _, launch = self.run_verb(["toggle", "--wait"])
|
||||||
|
launch.assert_not_called()
|
||||||
|
|
||||||
|
def test_asking_it_to_stop_when_it_is_not_going_is_not_a_failure(self):
|
||||||
|
for verb in ("cancel", "quit", "restart", "ask-reset"):
|
||||||
|
with self.subTest(verb=verb):
|
||||||
|
code, _, _, _ = self.run_verb([verb])
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
|
||||||
|
def test_anything_else_says_nothing_is_running(self):
|
||||||
|
for argv in (["record"], ["start"]):
|
||||||
|
with self.subTest(argv=argv):
|
||||||
|
code, _, err, _ = self.run_verb(argv)
|
||||||
|
self.assertEqual(code, cli.NOT_RUNNING)
|
||||||
|
self.assertIn("not running", err)
|
||||||
|
|
||||||
|
def test_status_answers_the_question_rather_than_failing_it(self):
|
||||||
|
""""Is it running" has an answer when it is not, and it goes to stdout."""
|
||||||
|
code, out, _, _ = self.run_verb(["status"])
|
||||||
|
self.assertEqual(code, cli.NOT_RUNNING)
|
||||||
|
self.assertIn("not running", out)
|
||||||
|
|
||||||
|
def test_status_as_json_says_so_in_a_field(self):
|
||||||
|
_, out, _, _ = self.run_verb(["--json", "status"])
|
||||||
|
self.assertFalse(json.loads(out)["running"])
|
||||||
|
|
||||||
|
def test_the_answer_says_so_in_json_too(self):
|
||||||
|
code, out, _, _ = self.run_verb(["--json", "record"])
|
||||||
|
self.assertEqual(code, cli.NOT_RUNNING)
|
||||||
|
payload = json.loads(out)
|
||||||
|
self.assertFalse(payload["ok"])
|
||||||
|
self.assertFalse(payload["running"])
|
||||||
|
|
||||||
|
def test_a_verb_that_needs_nothing_running_still_works(self):
|
||||||
|
with captured() as (out, _):
|
||||||
|
code = cli.run(["config", "get", "cleanup_model"])
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
self.assertEqual(out.getvalue().strip(), cfg.DEFAULTS["cleanup_model"])
|
||||||
|
|
||||||
|
|
||||||
|
class Replies(DikteTest):
|
||||||
|
"""What the instance said, turned into output and an exit code."""
|
||||||
|
|
||||||
|
def run_verb(self, argv, reply):
|
||||||
|
with mock.patch.object(ipc, "send", return_value=reply), \
|
||||||
|
captured() as (out, err):
|
||||||
|
code = cli.run(argv)
|
||||||
|
return code, out.getvalue(), err.getvalue()
|
||||||
|
|
||||||
|
def test_a_dictation_prints_its_transcript(self):
|
||||||
|
code, out, _ = self.run_verb(["stop", "--wait"],
|
||||||
|
{"ok": True, "text": "Book it for Thursday."})
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
self.assertEqual(out.strip(), "Book it for Thursday.")
|
||||||
|
|
||||||
|
def test_a_dictation_that_failed(self):
|
||||||
|
code, out, err = self.run_verb(["stop", "--wait"],
|
||||||
|
{"ok": False, "error": "No speech detected"})
|
||||||
|
self.assertEqual(code, 1)
|
||||||
|
self.assertEqual(out, "")
|
||||||
|
self.assertIn("No speech", err)
|
||||||
|
|
||||||
|
def test_a_warning_goes_to_stderr_beside_the_answer(self):
|
||||||
|
code, out, err = self.run_verb(
|
||||||
|
["stop", "--wait"],
|
||||||
|
{"ok": True, "text": "hello", "warning": "cleanup failed"})
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
self.assertEqual(out.strip(), "hello")
|
||||||
|
self.assertIn("cleanup failed", err)
|
||||||
|
|
||||||
|
def test_a_verb_with_nothing_to_say_prints_nothing(self):
|
||||||
|
code, out, _ = self.run_verb(["restart"], {"ok": True})
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
self.assertEqual(out, "")
|
||||||
|
|
||||||
|
def test_cancelling_something_that_is_not_running_is_not_a_failure(self):
|
||||||
|
with mock.patch.object(ipc, "send", return_value={"ok": True, "legacy": True}), \
|
||||||
|
captured():
|
||||||
|
self.assertEqual(cli.run(["cancel"]), 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
"""Settings, the history file and the meeting index.
|
||||||
|
|
||||||
|
Every one of these lives on disk and outlives an update, so the tests care most
|
||||||
|
about what happens to a file written by an older version: an unknown key, a
|
||||||
|
setting stored under its Turkish name, a prompt that used to be copied into the
|
||||||
|
config and now shadows the default.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import api
|
||||||
|
import config as cfg
|
||||||
|
import i18n
|
||||||
|
from tests.support import DikteTest
|
||||||
|
|
||||||
|
|
||||||
|
class Loading(DikteTest):
|
||||||
|
def test_nothing_stored_yet(self):
|
||||||
|
conf = cfg.Config()
|
||||||
|
self.assertEqual(conf["cleanup_model"], cfg.DEFAULTS["cleanup_model"])
|
||||||
|
|
||||||
|
def test_a_stored_value_wins(self):
|
||||||
|
self.write_config({"cleanup_model": "some/other-model"})
|
||||||
|
self.assertEqual(cfg.Config()["cleanup_model"], "some/other-model")
|
||||||
|
|
||||||
|
def test_a_key_this_version_does_not_have_is_dropped(self):
|
||||||
|
"""A setting from a fork, or from a version that removed it."""
|
||||||
|
self.write_config({"cleanup_model": "kept", "invented_by_a_fork": True})
|
||||||
|
conf = cfg.Config()
|
||||||
|
self.assertEqual(conf["cleanup_model"], "kept")
|
||||||
|
self.assertNotIn("invented_by_a_fork", conf.data)
|
||||||
|
|
||||||
|
def test_a_config_that_is_not_json_falls_back_to_the_defaults(self):
|
||||||
|
cfg.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
cfg.CONFIG_FILE.write_text("{not json", encoding="utf-8")
|
||||||
|
with mock.patch("builtins.print"):
|
||||||
|
conf = cfg.Config()
|
||||||
|
self.assertEqual(conf["cleanup_model"], cfg.DEFAULTS["cleanup_model"])
|
||||||
|
|
||||||
|
def test_a_config_that_is_json_but_not_an_object(self):
|
||||||
|
cfg.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
cfg.CONFIG_FILE.write_text("[1, 2]", encoding="utf-8")
|
||||||
|
self.assertEqual(cfg.Config()["cleanup_model"], cfg.DEFAULTS["cleanup_model"])
|
||||||
|
|
||||||
|
def test_a_corner_stored_under_its_old_turkish_name(self):
|
||||||
|
self.write_config({"overlay_corner": "sağ-üst"})
|
||||||
|
self.assertEqual(cfg.Config()["overlay_corner"], "top-right")
|
||||||
|
|
||||||
|
def test_a_corner_that_needs_no_migrating(self):
|
||||||
|
self.write_config({"overlay_corner": "bottom-right"})
|
||||||
|
self.assertEqual(cfg.Config()["overlay_corner"], "bottom-right")
|
||||||
|
|
||||||
|
def test_a_default_prompt_an_old_version_copied_in_is_dropped(self):
|
||||||
|
"""Otherwise it shadows every later improvement to that default."""
|
||||||
|
old = "the 1.2 default prompt, whatever it said"
|
||||||
|
with mock.patch.object(cfg, "LEGACY_PROMPTS", {cfg._fingerprint(old)}):
|
||||||
|
self.write_config({"cleanup_prompt": old})
|
||||||
|
self.assertEqual(cfg.Config()["cleanup_prompt"], "")
|
||||||
|
|
||||||
|
def test_a_prompt_the_user_wrote_is_left_alone(self):
|
||||||
|
self.write_config({"cleanup_prompt": "Only fix the punctuation."})
|
||||||
|
self.assertEqual(cfg.Config()["cleanup_prompt"], "Only fix the punctuation.")
|
||||||
|
|
||||||
|
def test_loading_sets_the_interface_language(self):
|
||||||
|
self.write_config({"ui_language": "tr"})
|
||||||
|
cfg.Config()
|
||||||
|
self.assertEqual(i18n.language(), "tr")
|
||||||
|
|
||||||
|
def test_an_unknown_key_reads_as_its_default(self):
|
||||||
|
self.assertIsNone(cfg.Config()["no_such_setting"])
|
||||||
|
self.assertEqual(cfg.Config().get("no_such_setting", "fallback"), "fallback")
|
||||||
|
|
||||||
|
|
||||||
|
class Saving(DikteTest):
|
||||||
|
def test_a_saved_setting_comes_back(self):
|
||||||
|
conf = cfg.Config()
|
||||||
|
conf["cleanup_model"] = "some/model"
|
||||||
|
conf.save()
|
||||||
|
self.assertEqual(cfg.Config()["cleanup_model"], "some/model")
|
||||||
|
|
||||||
|
def test_the_directory_is_created(self):
|
||||||
|
self.assertFalse(cfg.CONFIG_DIR.exists())
|
||||||
|
cfg.Config().save()
|
||||||
|
self.assertTrue(cfg.CONFIG_FILE.exists())
|
||||||
|
|
||||||
|
def test_the_file_is_readable_by_nobody_else(self):
|
||||||
|
"""It holds two API keys."""
|
||||||
|
cfg.Config().save()
|
||||||
|
self.assertEqual(cfg.CONFIG_FILE.stat().st_mode & 0o777, 0o600)
|
||||||
|
|
||||||
|
def test_nothing_is_left_behind_half_written(self):
|
||||||
|
cfg.Config().save()
|
||||||
|
self.assertEqual([p.name for p in cfg.CONFIG_DIR.iterdir()], ["config.json"])
|
||||||
|
|
||||||
|
def test_turkish_is_stored_as_turkish(self):
|
||||||
|
conf = cfg.Config()
|
||||||
|
conf["transcribe_prompt"] = "Paraşüt, öğle"
|
||||||
|
conf.save()
|
||||||
|
self.assertIn("Paraşüt", cfg.CONFIG_FILE.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
def test_saving_applies_the_interface_language(self):
|
||||||
|
conf = cfg.Config()
|
||||||
|
conf["ui_language"] = "tr"
|
||||||
|
conf.save()
|
||||||
|
self.assertEqual(i18n.language(), "tr")
|
||||||
|
|
||||||
|
|
||||||
|
class Keys(DikteTest):
|
||||||
|
def test_a_stored_key_is_used(self):
|
||||||
|
conf = self.config(openai_api_key=" sk-stored ")
|
||||||
|
self.assertEqual(conf.openai_key(), "sk-stored")
|
||||||
|
|
||||||
|
def test_the_environment_is_the_fallback(self):
|
||||||
|
with mock.patch.dict(os.environ, {"OPENAI_API_KEY": "sk-env"}):
|
||||||
|
self.assertEqual(cfg.Config().openai_key(), "sk-env")
|
||||||
|
|
||||||
|
def test_a_stored_key_beats_the_environment(self):
|
||||||
|
with mock.patch.dict(os.environ, {"OPENROUTER_API_KEY": "sk-env"}):
|
||||||
|
conf = self.config(openrouter_api_key="sk-stored")
|
||||||
|
self.assertEqual(conf.openrouter_key(), "sk-stored")
|
||||||
|
|
||||||
|
def test_no_key_anywhere(self):
|
||||||
|
self.assertEqual(cfg.Config().openai_key(), "")
|
||||||
|
|
||||||
|
|
||||||
|
class TranscribeTarget(DikteTest):
|
||||||
|
def test_openai_by_default(self):
|
||||||
|
target = self.config(openai_api_key="sk-test").transcribe_target()
|
||||||
|
self.assertEqual(target.provider, "openai")
|
||||||
|
self.assertEqual(target.service, "OpenAI")
|
||||||
|
self.assertEqual(target.api_key, "sk-test")
|
||||||
|
self.assertEqual(target.base_url, api.OPENAI_URL)
|
||||||
|
self.assertEqual(target.model, cfg.DEFAULTS["transcribe_model"])
|
||||||
|
|
||||||
|
def test_openrouter_when_it_is_picked(self):
|
||||||
|
conf = self.config(transcribe_provider="openrouter",
|
||||||
|
openrouter_api_key="sk-or-test",
|
||||||
|
openrouter_transcribe_model="openai/whisper-1")
|
||||||
|
target = conf.transcribe_target()
|
||||||
|
self.assertEqual(target.provider, "openrouter")
|
||||||
|
self.assertEqual(target.service, "OpenRouter")
|
||||||
|
self.assertEqual(target.api_key, "sk-or-test")
|
||||||
|
self.assertEqual(target.model, "openai/whisper-1")
|
||||||
|
|
||||||
|
def test_a_self_hosted_endpoint(self):
|
||||||
|
conf = self.config(openai_base_url="http://localhost:8080/v1")
|
||||||
|
self.assertEqual(conf.transcribe_target().base_url, "http://localhost:8080/v1")
|
||||||
|
|
||||||
|
|
||||||
|
class CleanupPrompt(DikteTest):
|
||||||
|
def test_the_default_follows_the_interface_language(self):
|
||||||
|
self.assertEqual(cfg.Config().cleanup_prompt(), cfg.CLEANUP_PROMPT_EN)
|
||||||
|
# Building a Config applies the stored language, so it is set there
|
||||||
|
# rather than around it.
|
||||||
|
self.write_config({"ui_language": "tr"})
|
||||||
|
self.assertEqual(cfg.Config().cleanup_prompt(), cfg.CLEANUP_PROMPT_TR)
|
||||||
|
|
||||||
|
def test_a_prompt_of_your_own(self):
|
||||||
|
conf = self.config(cleanup_prompt=" Only fix punctuation. ")
|
||||||
|
self.assertEqual(conf.cleanup_prompt(), "Only fix punctuation.")
|
||||||
|
|
||||||
|
def test_the_glossary_is_appended(self):
|
||||||
|
conf = self.config(transcribe_prompt="Paraşüt, OpenFrame")
|
||||||
|
self.assertIn("Paraşüt, OpenFrame", conf.cleanup_prompt())
|
||||||
|
|
||||||
|
def test_no_glossary_means_no_rule_about_one(self):
|
||||||
|
self.assertEqual(cfg.Config().cleanup_prompt(), cfg.CLEANUP_PROMPT_EN)
|
||||||
|
|
||||||
|
def test_subtitles_use_their_own_prompt(self):
|
||||||
|
conf = cfg.Config()
|
||||||
|
self.assertNotEqual(conf.cleanup_prompt(subtitles=True), conf.cleanup_prompt())
|
||||||
|
self.assertEqual(conf.cleanup_prompt(subtitles=True),
|
||||||
|
cfg.FILE_CLEANUP_PROMPT_EN)
|
||||||
|
|
||||||
|
def test_a_subtitle_prompt_of_your_own(self):
|
||||||
|
conf = self.config(file_cleanup_prompt="Keep the stamps.")
|
||||||
|
self.assertEqual(conf.cleanup_prompt(subtitles=True), "Keep the stamps.")
|
||||||
|
self.assertEqual(conf.cleanup_prompt(), cfg.CLEANUP_PROMPT_EN)
|
||||||
|
|
||||||
|
def test_timestamps_add_a_rule_about_them(self):
|
||||||
|
conf = cfg.Config()
|
||||||
|
self.assertGreater(len(conf.cleanup_prompt(with_timestamps=True)),
|
||||||
|
len(conf.cleanup_prompt()))
|
||||||
|
|
||||||
|
def test_speakers_bring_the_names_in_with_them(self):
|
||||||
|
conf = self.config(meeting_self_name="Yusuf", meeting_other_name="Ayşe")
|
||||||
|
prompt = conf.cleanup_prompt(with_speakers=True)
|
||||||
|
self.assertIn("Yusuf", prompt)
|
||||||
|
self.assertIn("Ayşe", prompt)
|
||||||
|
|
||||||
|
|
||||||
|
class Participants(DikteTest):
|
||||||
|
def test_nobody_named(self):
|
||||||
|
self.assertEqual(cfg.Config().participants(), "")
|
||||||
|
|
||||||
|
def test_the_two_sides_come_first(self):
|
||||||
|
conf = self.config(meeting_self_name="Yusuf", meeting_other_name="Ayşe",
|
||||||
|
meeting_participants="Mehmet")
|
||||||
|
self.assertEqual(conf.participants(), "Yusuf\nAyşe\nMehmet")
|
||||||
|
|
||||||
|
def test_commas_and_newlines_both_separate(self):
|
||||||
|
conf = self.config(meeting_participants="Ayşe, Mehmet\nZeynep")
|
||||||
|
self.assertEqual(conf.participants().splitlines(),
|
||||||
|
["Ayşe", "Mehmet", "Zeynep"])
|
||||||
|
|
||||||
|
def test_a_name_listed_twice_appears_once(self):
|
||||||
|
conf = self.config(meeting_self_name="Yusuf",
|
||||||
|
meeting_participants="yusuf, Ayşe")
|
||||||
|
self.assertEqual(conf.participants(), "Yusuf\nAyşe")
|
||||||
|
|
||||||
|
def test_blank_entries_are_dropped(self):
|
||||||
|
conf = self.config(meeting_participants="Ayşe,, ,\nMehmet")
|
||||||
|
self.assertEqual(conf.participants(), "Ayşe\nMehmet")
|
||||||
|
|
||||||
|
|
||||||
|
class MeetingSettings(DikteTest):
|
||||||
|
def test_the_hint_carries_the_glossary_and_the_names(self):
|
||||||
|
conf = self.config(transcribe_prompt="OpenFrame", meeting_self_name="Yusuf")
|
||||||
|
self.assertEqual(conf.meeting_hint(), "OpenFrame\nYusuf")
|
||||||
|
|
||||||
|
def test_the_hint_with_neither(self):
|
||||||
|
self.assertEqual(cfg.Config().meeting_hint(), "")
|
||||||
|
|
||||||
|
def test_the_speaker_labels_fall_back_to_the_language(self):
|
||||||
|
self.assertEqual(cfg.Config().speaker_names(), ("Me", "Other side"))
|
||||||
|
self.write_config({"ui_language": "tr"})
|
||||||
|
self.assertEqual(cfg.Config().speaker_names(), ("Ben", "Karşı taraf"))
|
||||||
|
|
||||||
|
def test_named_speakers_are_used_as_given(self):
|
||||||
|
conf = self.config(meeting_self_name="Yusuf", meeting_other_name="Ayşe")
|
||||||
|
self.assertEqual(conf.speaker_names(), ("Yusuf", "Ayşe"))
|
||||||
|
|
||||||
|
def test_the_meeting_prompt_lists_who_was_there(self):
|
||||||
|
conf = self.config(meeting_self_name="Yusuf", meeting_other_name="Ayşe")
|
||||||
|
self.assertIn("Yusuf", conf.meeting_prompt())
|
||||||
|
|
||||||
|
def test_the_meeting_prompt_with_nobody_named(self):
|
||||||
|
self.assertEqual(cfg.Config().meeting_prompt(), cfg.MEETING_PROMPT_EN)
|
||||||
|
|
||||||
|
|
||||||
|
class History(DikteTest):
|
||||||
|
def entry(self, text):
|
||||||
|
return {"ts": "2026-08-01 10:00:00", "text": text, "raw": text}
|
||||||
|
|
||||||
|
def test_nothing_written_yet(self):
|
||||||
|
self.assertEqual(cfg.read_history(), [])
|
||||||
|
|
||||||
|
def test_what_goes_in_comes_out_newest_last(self):
|
||||||
|
for text in ("first", "second"):
|
||||||
|
cfg.append_history(self.entry(text))
|
||||||
|
self.assertEqual([row["text"] for row in cfg.read_history()],
|
||||||
|
["first", "second"])
|
||||||
|
|
||||||
|
def test_a_limit_reads_the_tail(self):
|
||||||
|
for index in range(5):
|
||||||
|
cfg.append_history(self.entry(str(index)))
|
||||||
|
self.assertEqual([row["text"] for row in cfg.read_history(2)], ["3", "4"])
|
||||||
|
|
||||||
|
def test_a_limit_of_zero_reads_everything(self):
|
||||||
|
for index in range(3):
|
||||||
|
cfg.append_history(self.entry(str(index)))
|
||||||
|
self.assertEqual(len(cfg.read_history(0)), 3)
|
||||||
|
|
||||||
|
def test_a_line_that_is_not_json_is_skipped_rather_than_fatal(self):
|
||||||
|
cfg.append_history(self.entry("good"))
|
||||||
|
with open(cfg.HISTORY_FILE, "a", encoding="utf-8") as fh:
|
||||||
|
fh.write("half a line, no newline at the end of the world\n")
|
||||||
|
cfg.append_history(self.entry("also good"))
|
||||||
|
self.assertEqual([row["text"] for row in cfg.read_history()],
|
||||||
|
["good", "also good"])
|
||||||
|
|
||||||
|
def test_turkish_survives_the_round_trip(self):
|
||||||
|
cfg.append_history(self.entry("Öğleden sonra görüşürüz."))
|
||||||
|
self.assertEqual(cfg.read_history()[0]["text"], "Öğleden sonra görüşürüz.")
|
||||||
|
|
||||||
|
def test_trimming_keeps_the_newest(self):
|
||||||
|
for index in range(10):
|
||||||
|
cfg.append_history(self.entry(str(index)))
|
||||||
|
cfg.trim_history(3)
|
||||||
|
self.assertEqual([row["text"] for row in cfg.read_history()],
|
||||||
|
["7", "8", "9"])
|
||||||
|
|
||||||
|
def test_a_limit_of_zero_keeps_everything(self):
|
||||||
|
for index in range(4):
|
||||||
|
cfg.append_history(self.entry(str(index)))
|
||||||
|
cfg.trim_history(0)
|
||||||
|
self.assertEqual(len(cfg.read_history()), 4)
|
||||||
|
|
||||||
|
def test_trimming_a_file_that_is_already_short_enough(self):
|
||||||
|
cfg.append_history(self.entry("only one"))
|
||||||
|
cfg.trim_history(200)
|
||||||
|
self.assertEqual(len(cfg.read_history()), 1)
|
||||||
|
|
||||||
|
def test_trimming_before_anything_was_written(self):
|
||||||
|
cfg.trim_history(10) # must not raise
|
||||||
|
|
||||||
|
def test_deleting_matches_on_content_not_on_position(self):
|
||||||
|
"""The worker may have appended a row since the list was read."""
|
||||||
|
rows = [self.entry("a"), self.entry("b"), self.entry("c")]
|
||||||
|
for row in rows:
|
||||||
|
cfg.append_history(row)
|
||||||
|
cfg.delete_history([rows[1]])
|
||||||
|
self.assertEqual([row["text"] for row in cfg.read_history()], ["a", "c"])
|
||||||
|
|
||||||
|
def test_deleting_is_insensitive_to_key_order(self):
|
||||||
|
cfg.append_history({"ts": "now", "text": "hello"})
|
||||||
|
cfg.delete_history([{"text": "hello", "ts": "now"}])
|
||||||
|
self.assertEqual(cfg.read_history(), [])
|
||||||
|
|
||||||
|
def test_deleting_nothing_touches_nothing(self):
|
||||||
|
cfg.append_history(self.entry("a"))
|
||||||
|
cfg.delete_history([])
|
||||||
|
self.assertEqual(len(cfg.read_history()), 1)
|
||||||
|
|
||||||
|
def test_clearing(self):
|
||||||
|
cfg.append_history(self.entry("a"))
|
||||||
|
cfg.clear_history()
|
||||||
|
self.assertEqual(cfg.read_history(), [])
|
||||||
|
|
||||||
|
def test_clearing_a_history_that_is_not_there(self):
|
||||||
|
cfg.clear_history() # must not raise
|
||||||
|
|
||||||
|
|
||||||
|
class Meetings(DikteTest):
|
||||||
|
def entry(self, base, **changes):
|
||||||
|
row = {"base": base, "ts": "2026-08-01 10:00", "title": "",
|
||||||
|
"duration": 60.0, "status": "recorded", "error": "", "model": ""}
|
||||||
|
row.update(changes)
|
||||||
|
return row
|
||||||
|
|
||||||
|
def test_nothing_recorded_yet(self):
|
||||||
|
self.assertEqual(cfg.read_meetings(), [])
|
||||||
|
|
||||||
|
def test_the_document_and_the_recording_share_a_stem(self):
|
||||||
|
doc, wav = cfg.meeting_paths("20260801-100000")
|
||||||
|
self.assertEqual(doc.name, "20260801-100000.md")
|
||||||
|
self.assertEqual(wav.name, "20260801-100000.wav")
|
||||||
|
self.assertEqual(doc.parent, cfg.MEETINGS_DIR)
|
||||||
|
|
||||||
|
def test_saving_and_reading_back(self):
|
||||||
|
cfg.save_meeting(self.entry("a"))
|
||||||
|
cfg.save_meeting(self.entry("b"))
|
||||||
|
self.assertEqual([row["base"] for row in cfg.read_meetings()], ["a", "b"])
|
||||||
|
|
||||||
|
def test_saving_the_same_base_replaces_rather_than_appends(self):
|
||||||
|
cfg.save_meeting(self.entry("a"))
|
||||||
|
cfg.save_meeting(self.entry("a", status="done"))
|
||||||
|
rows = cfg.read_meetings()
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
self.assertEqual(rows[0]["status"], "done")
|
||||||
|
|
||||||
|
def test_a_row_with_no_base_is_ignored(self):
|
||||||
|
cfg.DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
cfg.MEETINGS_FILE.write_text(
|
||||||
|
json.dumps({"title": "orphan"}) + "\n" + json.dumps(self.entry("a")) + "\n",
|
||||||
|
encoding="utf-8")
|
||||||
|
self.assertEqual([row["base"] for row in cfg.read_meetings()], ["a"])
|
||||||
|
|
||||||
|
def test_a_broken_line_is_skipped(self):
|
||||||
|
cfg.save_meeting(self.entry("a"))
|
||||||
|
with open(cfg.MEETINGS_FILE, "a", encoding="utf-8") as fh:
|
||||||
|
fh.write("{oh dear\n")
|
||||||
|
self.assertEqual(len(cfg.read_meetings()), 1)
|
||||||
|
|
||||||
|
def test_updating_patches_and_hands_the_row_back(self):
|
||||||
|
cfg.save_meeting(self.entry("a"))
|
||||||
|
row = cfg.update_meeting("a", status="done", title="Kickoff")
|
||||||
|
self.assertEqual(row["status"], "done")
|
||||||
|
self.assertEqual(cfg.read_meetings()[0]["title"], "Kickoff")
|
||||||
|
|
||||||
|
def test_updating_one_that_is_gone(self):
|
||||||
|
self.assertIsNone(cfg.update_meeting("nope", status="done"))
|
||||||
|
|
||||||
|
def test_deleting_takes_the_files_with_it(self):
|
||||||
|
cfg.save_meeting(self.entry("a"))
|
||||||
|
doc, wav = cfg.meeting_paths("a")
|
||||||
|
doc.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
doc.write_text("# minutes", encoding="utf-8")
|
||||||
|
wav.write_bytes(b"RIFF")
|
||||||
|
cfg.delete_meetings(["a"])
|
||||||
|
self.assertEqual(cfg.read_meetings(), [])
|
||||||
|
self.assertFalse(doc.exists())
|
||||||
|
self.assertFalse(wav.exists())
|
||||||
|
|
||||||
|
def test_deleting_a_row_whose_files_are_already_gone(self):
|
||||||
|
cfg.save_meeting(self.entry("a"))
|
||||||
|
cfg.delete_meetings(["a"]) # must not raise
|
||||||
|
self.assertEqual(cfg.read_meetings(), [])
|
||||||
|
|
||||||
|
def test_deleting_nothing(self):
|
||||||
|
cfg.save_meeting(self.entry("a"))
|
||||||
|
cfg.delete_meetings([])
|
||||||
|
self.assertEqual(len(cfg.read_meetings()), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class Defaults(unittest.TestCase):
|
||||||
|
"""The table itself, which every command line and settings tab reads."""
|
||||||
|
|
||||||
|
def test_no_setting_defaults_to_none(self):
|
||||||
|
"""cli._coerce switches on the type of the default, so there has to be one."""
|
||||||
|
for key, value in cfg.DEFAULTS.items():
|
||||||
|
with self.subTest(key=key):
|
||||||
|
self.assertIsNotNone(value)
|
||||||
|
|
||||||
|
def test_the_prompts_ship_empty_so_the_default_can_improve(self):
|
||||||
|
for key in ("cleanup_prompt", "file_cleanup_prompt", "meeting_prompt",
|
||||||
|
"assistant_prompt"):
|
||||||
|
with self.subTest(key=key):
|
||||||
|
self.assertEqual(cfg.DEFAULTS[key], "")
|
||||||
|
|
||||||
|
def test_the_keys_ship_empty(self):
|
||||||
|
self.assertEqual(cfg.DEFAULTS["openai_api_key"], "")
|
||||||
|
self.assertEqual(cfg.DEFAULTS["openrouter_api_key"], "")
|
||||||
|
|
||||||
|
def test_every_language_specific_prompt_has_both_languages(self):
|
||||||
|
for name in ("CLEANUP_PROMPT", "FILE_CLEANUP_PROMPT", "MEETING_PROMPT",
|
||||||
|
"ASSISTANT_PROMPT"):
|
||||||
|
for suffix in ("EN", "TR"):
|
||||||
|
with self.subTest(prompt=f"{name}_{suffix}"):
|
||||||
|
self.assertTrue(getattr(cfg, f"{name}_{suffix}").strip())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"""Transcribing a file: splitting it, stamping it, and writing subtitles.
|
||||||
|
|
||||||
|
to_srt is the awkward one. The text is the authority on wording and the segments
|
||||||
|
on timing, and they meet at a whole-second stamp that a cleanup model was asked
|
||||||
|
to leave alone. It has to survive a model that wrapped a line, dropped one, or
|
||||||
|
made up a stamp nobody recorded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import unittest
|
||||||
|
import wave
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import api
|
||||||
|
import filetranscribe as ft
|
||||||
|
from tests.support import DikteTest, make_wav, silence, tone
|
||||||
|
|
||||||
|
|
||||||
|
class Timestamps(unittest.TestCase):
|
||||||
|
def test_under_an_hour(self):
|
||||||
|
self.assertEqual(ft.format_timestamp(0), "00:00")
|
||||||
|
self.assertEqual(ft.format_timestamp(65.9), "01:05")
|
||||||
|
self.assertEqual(ft.format_timestamp(599), "09:59")
|
||||||
|
|
||||||
|
def test_past_an_hour_the_hours_show(self):
|
||||||
|
self.assertEqual(ft.format_timestamp(3600), "1:00:00")
|
||||||
|
self.assertEqual(ft.format_timestamp(3725), "1:02:05")
|
||||||
|
|
||||||
|
def test_srt_wants_milliseconds_and_a_comma(self):
|
||||||
|
self.assertEqual(ft.srt_timestamp(0), "00:00:00,000")
|
||||||
|
self.assertEqual(ft.srt_timestamp(1.5), "00:00:01,500")
|
||||||
|
self.assertEqual(ft.srt_timestamp(3725.25), "01:02:05,250")
|
||||||
|
|
||||||
|
def test_a_negative_start_is_pulled_up_to_zero(self):
|
||||||
|
self.assertEqual(ft.srt_timestamp(-3), "00:00:00,000")
|
||||||
|
|
||||||
|
|
||||||
|
class ToSrt(unittest.TestCase):
|
||||||
|
def test_nothing_to_do(self):
|
||||||
|
self.assertEqual(ft.to_srt("", []), "")
|
||||||
|
self.assertEqual(ft.to_srt("no stamps here", []), "")
|
||||||
|
|
||||||
|
def test_a_cue_takes_its_timing_from_the_segment_it_came_from(self):
|
||||||
|
srt = ft.to_srt("[00:01] Hello there.", [(1.25, 2.75, "hello there")])
|
||||||
|
self.assertIn("00:00:01,250 --> 00:00:02,750", srt)
|
||||||
|
self.assertIn("Hello there.", srt)
|
||||||
|
|
||||||
|
def test_the_text_wins_on_wording(self):
|
||||||
|
"""Cleanup edits survive; only the timing comes from the segments."""
|
||||||
|
srt = ft.to_srt("[00:01] Hello there.", [(1.0, 2.0, "uh hello uh there")])
|
||||||
|
self.assertIn("Hello there.", srt)
|
||||||
|
self.assertNotIn("uh", srt)
|
||||||
|
|
||||||
|
def test_cues_are_numbered_from_one(self):
|
||||||
|
srt = ft.to_srt("[00:00] One\n[00:02] Two",
|
||||||
|
[(0.0, 1.0, "One"), (2.0, 3.0, "Two")])
|
||||||
|
self.assertTrue(srt.startswith("1\n"))
|
||||||
|
self.assertIn("\n2\n", srt)
|
||||||
|
|
||||||
|
def test_a_wrapped_line_joins_the_cue_above_it(self):
|
||||||
|
srt = ft.to_srt("[00:01] Hello there,\nand welcome.",
|
||||||
|
[(1.0, 4.0, "hello there and welcome")])
|
||||||
|
self.assertIn("Hello there, and welcome.", srt)
|
||||||
|
self.assertEqual(srt.count(" --> "), 1)
|
||||||
|
|
||||||
|
def test_a_stamp_nobody_recorded_still_gets_timing(self):
|
||||||
|
srt = ft.to_srt("[00:05] Invented.", [])
|
||||||
|
self.assertIn("00:00:05,000 --> ", srt)
|
||||||
|
|
||||||
|
def test_a_cue_with_no_end_runs_until_the_next_one(self):
|
||||||
|
srt = ft.to_srt("[00:00] One\n[00:04] Two", [])
|
||||||
|
self.assertIn("00:00:00,000 --> 00:00:04,000", srt)
|
||||||
|
|
||||||
|
def test_the_last_cue_gets_a_minimum_length(self):
|
||||||
|
srt = ft.to_srt("[00:10] Last words.", [])
|
||||||
|
self.assertIn("00:00:10,000 --> 00:00:11,500", srt)
|
||||||
|
|
||||||
|
def test_a_cue_is_cut_short_when_the_next_one_starts_first(self):
|
||||||
|
"""Whisper's end times overlap now and then; subtitles must not."""
|
||||||
|
srt = ft.to_srt("[00:00] One\n[00:02] Two",
|
||||||
|
[(0.0, 9.0, "One"), (2.0, 3.0, "Two")])
|
||||||
|
self.assertIn("00:00:00,000 --> 00:00:02,000", srt)
|
||||||
|
|
||||||
|
def test_blank_lines_and_empty_cues_are_dropped(self):
|
||||||
|
srt = ft.to_srt("[00:00] One\n\n[00:02]\n[00:03] Three", [])
|
||||||
|
self.assertEqual(srt.count(" --> "), 2)
|
||||||
|
|
||||||
|
def test_the_hour_form_of_a_stamp_is_understood(self):
|
||||||
|
srt = ft.to_srt("[1:02:05] Late.", [])
|
||||||
|
self.assertIn("01:02:05,000", srt)
|
||||||
|
|
||||||
|
def test_the_file_ends_with_a_newline(self):
|
||||||
|
self.assertTrue(ft.to_srt("[00:00] One", []).endswith("\n"))
|
||||||
|
|
||||||
|
|
||||||
|
class SplitText(unittest.TestCase):
|
||||||
|
def test_short_text_stays_whole(self):
|
||||||
|
self.assertEqual(ft.split_text("hello", False), ["hello"])
|
||||||
|
|
||||||
|
def test_a_long_transcript_is_broken_up(self):
|
||||||
|
text = " ".join(["word"] * 8000)
|
||||||
|
blocks = ft.split_text(text, False)
|
||||||
|
self.assertGreater(len(blocks), 1)
|
||||||
|
for block in blocks:
|
||||||
|
self.assertLessEqual(len(block), ft.CLEANUP_CHUNK_CHARS)
|
||||||
|
|
||||||
|
def test_nothing_is_lost_in_the_splitting(self):
|
||||||
|
text = " ".join(f"word{index}" for index in range(4000))
|
||||||
|
self.assertEqual(" ".join(ft.split_text(text, False)), text)
|
||||||
|
|
||||||
|
def test_a_timestamped_transcript_is_never_broken_mid_line(self):
|
||||||
|
text = "\n".join(f"[00:{index:02d}] a line of some length here"
|
||||||
|
for index in range(600))
|
||||||
|
blocks = ft.split_text(text, True)
|
||||||
|
self.assertGreater(len(blocks), 1)
|
||||||
|
for block in blocks:
|
||||||
|
for line in block.splitlines():
|
||||||
|
self.assertTrue(line.startswith("["))
|
||||||
|
|
||||||
|
def test_a_single_line_longer_than_the_limit_is_kept_whole(self):
|
||||||
|
text = "x" * (ft.CLEANUP_CHUNK_CHARS + 100)
|
||||||
|
self.assertEqual(ft.split_text(text, True), [text])
|
||||||
|
|
||||||
|
|
||||||
|
class SplitWav(DikteTest):
|
||||||
|
def wav(self, seconds, name="in.wav"):
|
||||||
|
return make_wav(self.path(name), silence(seconds))
|
||||||
|
|
||||||
|
def test_a_short_file_is_handed_back_as_it_is(self):
|
||||||
|
path = self.wav(2)
|
||||||
|
self.assertEqual(ft.split_wav(path, self.root), [(path, 0.0)])
|
||||||
|
|
||||||
|
def test_a_long_file_is_cut_at_the_chunk_length(self):
|
||||||
|
path = self.wav(5)
|
||||||
|
with mock.patch.object(ft, "CHUNK_SECONDS", 2):
|
||||||
|
chunks = ft.split_wav(path, self.root)
|
||||||
|
self.assertEqual([offset for _, offset in chunks], [0, 2, 4])
|
||||||
|
|
||||||
|
def test_the_chunks_add_up_to_the_original(self):
|
||||||
|
path = self.wav(5)
|
||||||
|
with mock.patch.object(ft, "CHUNK_SECONDS", 2):
|
||||||
|
chunks = ft.split_wav(path, self.root)
|
||||||
|
total = 0
|
||||||
|
for chunk_path, _ in chunks:
|
||||||
|
with contextlib.closing(wave.open(chunk_path, "rb")) as wav:
|
||||||
|
total += wav.getnframes()
|
||||||
|
self.assertEqual(wav.getframerate(), 16000)
|
||||||
|
self.assertEqual(total, 5 * 16000)
|
||||||
|
|
||||||
|
def test_the_chunks_do_not_write_over_each_other(self):
|
||||||
|
path = self.wav(5)
|
||||||
|
with mock.patch.object(ft, "CHUNK_SECONDS", 2):
|
||||||
|
chunks = ft.split_wav(path, self.root)
|
||||||
|
self.assertEqual(len({chunk for chunk, _ in chunks}), len(chunks))
|
||||||
|
|
||||||
|
|
||||||
|
class Transcriber(DikteTest):
|
||||||
|
"""The chain, with ffmpeg and both API calls faked."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.source = make_wav(self.path("input.wav"), tone(1.0))
|
||||||
|
self.conf = self.config(openrouter_api_key="sk-or-test")
|
||||||
|
|
||||||
|
def run_chain(self, timestamps=False, cleanup=False, transcript="raw text",
|
||||||
|
segments=None, cleaned="clean text", fail=None):
|
||||||
|
worker = ft.FileTranscriber(self.conf)
|
||||||
|
done, failures, progress = [], [], []
|
||||||
|
worker.finished.connect(lambda *args: done.append(args))
|
||||||
|
worker.failed.connect(failures.append)
|
||||||
|
worker.progress.connect(progress.append)
|
||||||
|
|
||||||
|
def to_wav(path, workdir):
|
||||||
|
return make_wav(self.path("converted.wav"), tone(1.0))
|
||||||
|
|
||||||
|
with mock.patch.object(ft, "_to_wav", side_effect=to_wav), \
|
||||||
|
mock.patch.object(ft.shutil, "which", return_value="/usr/bin/ffmpeg"), \
|
||||||
|
mock.patch.object(api, "transcribe",
|
||||||
|
side_effect=fail or (lambda *a, **k: transcript)), \
|
||||||
|
mock.patch.object(api, "transcribe_segments",
|
||||||
|
return_value=segments or [(0.0, 1.0, "raw text")]), \
|
||||||
|
mock.patch.object(api, "cleanup", return_value=cleaned) as cleanup_call:
|
||||||
|
# The chain is run here rather than through start(): its signals are
|
||||||
|
# emitted from the worker thread, and a queued connection would need
|
||||||
|
# an event loop to deliver them. This is the same code, one frame down.
|
||||||
|
worker._work(self.source, timestamps, cleanup)
|
||||||
|
return done, failures, progress, cleanup_call
|
||||||
|
|
||||||
|
def test_plain_text_out(self):
|
||||||
|
done, failures, _, _ = self.run_chain()
|
||||||
|
self.assertEqual(failures, [])
|
||||||
|
self.assertEqual(done[0][0], "raw text")
|
||||||
|
|
||||||
|
def test_cleanup_replaces_the_text(self):
|
||||||
|
done, _, _, _ = self.run_chain(cleanup=True)
|
||||||
|
self.assertEqual(done[0][0], "clean text")
|
||||||
|
|
||||||
|
def test_cleanup_is_told_it_is_writing_subtitles(self):
|
||||||
|
_, _, _, cleanup_call = self.run_chain(cleanup=True)
|
||||||
|
prompt = cleanup_call.call_args.args[3]
|
||||||
|
self.assertEqual(prompt, self.conf.cleanup_prompt(subtitles=True))
|
||||||
|
|
||||||
|
def test_timestamps_come_back_as_segments_and_as_stamped_lines(self):
|
||||||
|
done, _, _, _ = self.run_chain(
|
||||||
|
timestamps=True, segments=[(0.0, 1.0, "one"), (2.0, 3.0, "two")])
|
||||||
|
text, segments = done[0]
|
||||||
|
self.assertEqual(text, "[00:00] one\n[00:02] two")
|
||||||
|
self.assertEqual(len(segments), 2)
|
||||||
|
|
||||||
|
def test_no_ffmpeg_installed(self):
|
||||||
|
worker = ft.FileTranscriber(self.conf)
|
||||||
|
failures = []
|
||||||
|
worker.failed.connect(failures.append)
|
||||||
|
with mock.patch.object(ft.shutil, "which", return_value=None):
|
||||||
|
worker._work(self.source, False, False)
|
||||||
|
self.assertIn("ffmpeg", failures[0])
|
||||||
|
|
||||||
|
def test_an_api_failure_is_reported_rather_than_raised(self):
|
||||||
|
def boom(*args, **kwargs):
|
||||||
|
raise api.ApiError("OpenAI rejected the API key")
|
||||||
|
_, failures, _, _ = self.run_chain(fail=boom)
|
||||||
|
self.assertIn("rejected", failures[0])
|
||||||
|
|
||||||
|
def test_empty_text_is_not_sent_to_cleanup(self):
|
||||||
|
_, _, _, cleanup_call = self.run_chain(cleanup=True, transcript="")
|
||||||
|
cleanup_call.assert_not_called()
|
||||||
|
|
||||||
|
def test_a_second_start_while_one_is_running_is_ignored(self):
|
||||||
|
worker = ft.FileTranscriber(self.conf)
|
||||||
|
worker._thread = mock.Mock(is_alive=lambda: True)
|
||||||
|
self.assertTrue(worker.busy)
|
||||||
|
worker.start(self.source, False, False)
|
||||||
|
self.assertTrue(worker.busy)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"""Parsing a shortcut, and the KDE entry it is installed as."""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import hotkey
|
||||||
|
from tests.support import DikteTest, FakeCompleted, linux_only
|
||||||
|
|
||||||
|
SHORTCUTS_RC = """[services][dikte-toggle.desktop]
|
||||||
|
_launch=Ctrl+Space
|
||||||
|
|
||||||
|
[services][org.kde.spectacle.desktop]
|
||||||
|
RectangularRegionScreenShot=Meta+Shift+Print\tMeta+Shift+Print\t
|
||||||
|
|
||||||
|
[kwin]
|
||||||
|
Overview=Meta+W,Meta+W,Toggle Overview
|
||||||
|
Switch Window Down=Meta+Alt+Down,Meta+Alt+Down,Switch to Window Below
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ParseShortcut(unittest.TestCase):
|
||||||
|
def test_the_default(self):
|
||||||
|
self.assertEqual(hotkey.parse_shortcut("Ctrl+Space"), ({"ctrl"}, 57))
|
||||||
|
|
||||||
|
def test_case_and_spacing_do_not_matter(self):
|
||||||
|
self.assertEqual(hotkey.parse_shortcut(" ctrl + SPACE "), ({"ctrl"}, 57))
|
||||||
|
|
||||||
|
def test_several_modifiers(self):
|
||||||
|
mods, key = hotkey.parse_shortcut("Ctrl+Alt+Shift+D")
|
||||||
|
self.assertEqual(mods, {"ctrl", "alt", "shift"})
|
||||||
|
self.assertEqual(key, 32)
|
||||||
|
|
||||||
|
def test_the_synonyms_land_on_one_name(self):
|
||||||
|
self.assertEqual(hotkey.parse_shortcut("Control+Space"),
|
||||||
|
hotkey.parse_shortcut("Ctrl+Space"))
|
||||||
|
self.assertEqual(hotkey.parse_shortcut("Meta+Space"),
|
||||||
|
hotkey.parse_shortcut("Super+Space"))
|
||||||
|
|
||||||
|
def test_a_key_on_its_own(self):
|
||||||
|
self.assertEqual(hotkey.parse_shortcut("F9"), (set(), 67))
|
||||||
|
|
||||||
|
def test_modifiers_with_no_key(self):
|
||||||
|
self.assertEqual(hotkey.parse_shortcut("Ctrl+Alt"), (None, None))
|
||||||
|
|
||||||
|
def test_a_key_nobody_mapped(self):
|
||||||
|
self.assertEqual(hotkey.parse_shortcut("Ctrl+F13"), (None, None))
|
||||||
|
|
||||||
|
def test_nothing(self):
|
||||||
|
self.assertEqual(hotkey.parse_shortcut(""), (None, None))
|
||||||
|
self.assertEqual(hotkey.parse_shortcut("+++"), (None, None))
|
||||||
|
|
||||||
|
def test_something_that_is_not_even_a_string(self):
|
||||||
|
self.assertEqual(hotkey.parse_shortcut(None), (None, None))
|
||||||
|
|
||||||
|
|
||||||
|
class ModsMatch(unittest.TestCase):
|
||||||
|
"""The combination has to be exact, or Ctrl+Space fires on Ctrl+Shift+Space."""
|
||||||
|
|
||||||
|
def match(self, held, wanted):
|
||||||
|
return hotkey.EvdevHotkey._mods_match(set(held), set(wanted))
|
||||||
|
|
||||||
|
def test_the_wanted_modifier_is_down(self):
|
||||||
|
self.assertTrue(self.match({29}, {"ctrl"}))
|
||||||
|
|
||||||
|
def test_either_side_of_the_keyboard_counts(self):
|
||||||
|
self.assertTrue(self.match({97}, {"ctrl"}))
|
||||||
|
|
||||||
|
def test_nothing_held_and_nothing_wanted(self):
|
||||||
|
self.assertTrue(self.match(set(), set()))
|
||||||
|
|
||||||
|
def test_a_modifier_too_many(self):
|
||||||
|
self.assertFalse(self.match({29, 42}, {"ctrl"}))
|
||||||
|
|
||||||
|
def test_a_modifier_missing(self):
|
||||||
|
self.assertFalse(self.match(set(), {"ctrl"}))
|
||||||
|
|
||||||
|
def test_the_wrong_modifier(self):
|
||||||
|
self.assertFalse(self.match({56}, {"ctrl"}))
|
||||||
|
|
||||||
|
|
||||||
|
@linux_only
|
||||||
|
class Bindings(DikteTest):
|
||||||
|
"""start() before it reaches /dev/input, which a test may not read."""
|
||||||
|
|
||||||
|
def test_a_binding_with_no_shortcut_is_skipped(self):
|
||||||
|
listener = hotkey.EvdevHotkey()
|
||||||
|
with mock.patch.object(listener, "_open_devices", return_value=[]):
|
||||||
|
self.assertFalse(listener.start({"toggle": "", "ask": ""}))
|
||||||
|
|
||||||
|
def test_an_unparsable_shortcut_is_reported_and_the_rest_go_on(self):
|
||||||
|
listener = hotkey.EvdevHotkey()
|
||||||
|
self.addCleanup(listener.stop)
|
||||||
|
failures = []
|
||||||
|
listener.failed.connect(failures.append)
|
||||||
|
with mock.patch.object(listener, "_open_devices", return_value=[99]), \
|
||||||
|
mock.patch.object(hotkey.threading, "Thread"):
|
||||||
|
self.assertTrue(listener.start({"toggle": "Ctrl+F13",
|
||||||
|
"ask": "Ctrl+Space"}))
|
||||||
|
self.assertEqual(len(failures), 1)
|
||||||
|
self.assertIn("Ctrl+F13", failures[0])
|
||||||
|
self.assertEqual(list(listener._bindings), [57])
|
||||||
|
|
||||||
|
def test_no_readable_devices_says_what_to_do_about_it(self):
|
||||||
|
listener = hotkey.EvdevHotkey()
|
||||||
|
failures = []
|
||||||
|
listener.failed.connect(failures.append)
|
||||||
|
with mock.patch.object(listener, "_open_devices", return_value=[]):
|
||||||
|
self.assertFalse(listener.start({"toggle": "Ctrl+Space"}))
|
||||||
|
self.assertIn("input", failures[0])
|
||||||
|
|
||||||
|
def test_two_shortcuts_on_one_key_are_both_kept(self):
|
||||||
|
listener = hotkey.EvdevHotkey()
|
||||||
|
self.addCleanup(listener.stop)
|
||||||
|
with mock.patch.object(listener, "_open_devices", return_value=[]), \
|
||||||
|
mock.patch.object(hotkey.threading, "Thread") as thread:
|
||||||
|
listener._open_devices.return_value = [99]
|
||||||
|
self.assertTrue(listener.start({"toggle": "Ctrl+Space",
|
||||||
|
"ask": "Ctrl+Alt+Space"}))
|
||||||
|
thread.assert_called_once()
|
||||||
|
self.assertEqual(len(listener._bindings[57]), 2)
|
||||||
|
|
||||||
|
|
||||||
|
@linux_only
|
||||||
|
class KdeShortcut(DikteTest):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.apps = self.path("applications")
|
||||||
|
self.apps.mkdir(parents=True)
|
||||||
|
self.rc = self.path("kglobalshortcutsrc")
|
||||||
|
self.patch_attr(hotkey, "APPLICATIONS_DIR", self.apps)
|
||||||
|
self.patch_attr(hotkey, "SHORTCUTS_FILE", self.rc)
|
||||||
|
|
||||||
|
def test_installing_writes_a_desktop_file_kwin_will_launch(self):
|
||||||
|
with mock.patch.object(subprocess, "run", return_value=FakeCompleted()):
|
||||||
|
ok, message = hotkey.install_kde_shortcut("Ctrl+Space", "dikte toggle")
|
||||||
|
self.assertTrue(ok)
|
||||||
|
text = (self.apps / hotkey.DESKTOP_ID).read_text(encoding="utf-8")
|
||||||
|
self.assertIn("Exec=dikte toggle", text)
|
||||||
|
self.assertIn("X-KDE-GlobalAccel-CommandShortcut=true", text)
|
||||||
|
self.assertIn("log out", message)
|
||||||
|
|
||||||
|
def test_the_shortcut_is_registered_under_the_desktop_id(self):
|
||||||
|
with mock.patch.object(subprocess, "run",
|
||||||
|
return_value=FakeCompleted()) as run:
|
||||||
|
hotkey.install_kde_shortcut("Meta+D", "dikte ask",
|
||||||
|
desktop_id=hotkey.ASK_DESKTOP_ID)
|
||||||
|
cmd = run.call_args.args[0]
|
||||||
|
self.assertEqual(cmd[0], "kwriteconfig6")
|
||||||
|
self.assertIn(hotkey.ASK_DESKTOP_ID, cmd)
|
||||||
|
self.assertEqual(cmd[-1], "Meta+D")
|
||||||
|
|
||||||
|
def test_each_verb_gets_its_own_entry(self):
|
||||||
|
with mock.patch.object(subprocess, "run", return_value=FakeCompleted()):
|
||||||
|
hotkey.install_kde_shortcut("Ctrl+Space", "dikte toggle")
|
||||||
|
hotkey.install_kde_shortcut("Meta+M", "dikte meeting",
|
||||||
|
desktop_id=hotkey.MEETING_DESKTOP_ID)
|
||||||
|
self.assertTrue((self.apps / hotkey.DESKTOP_ID).exists())
|
||||||
|
self.assertTrue((self.apps / hotkey.MEETING_DESKTOP_ID).exists())
|
||||||
|
|
||||||
|
def test_no_kwriteconfig_installed(self):
|
||||||
|
with mock.patch.object(subprocess, "run", side_effect=OSError("nope")):
|
||||||
|
ok, message = hotkey.install_kde_shortcut("Ctrl+Space", "dikte toggle")
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertIn("kglobalshortcutsrc", message)
|
||||||
|
|
||||||
|
def test_removing_takes_the_desktop_file_with_it(self):
|
||||||
|
(self.apps / hotkey.DESKTOP_ID).write_text("[Desktop Entry]", encoding="utf-8")
|
||||||
|
with mock.patch.object(subprocess, "run", return_value=FakeCompleted()) as run:
|
||||||
|
hotkey.remove_kde_shortcut()
|
||||||
|
self.assertFalse((self.apps / hotkey.DESKTOP_ID).exists())
|
||||||
|
self.assertIn("--delete", run.call_args.args[0])
|
||||||
|
|
||||||
|
def test_removing_one_that_was_never_installed(self):
|
||||||
|
with mock.patch.object(subprocess, "run", side_effect=OSError("nope")):
|
||||||
|
hotkey.remove_kde_shortcut() # must not raise
|
||||||
|
|
||||||
|
def test_the_registered_shortcut_is_read_back(self):
|
||||||
|
(self.apps / hotkey.DESKTOP_ID).write_text("[Desktop Entry]", encoding="utf-8")
|
||||||
|
self.rc.write_text(SHORTCUTS_RC, encoding="utf-8")
|
||||||
|
self.assertEqual(hotkey.kde_shortcut_status(), "Ctrl+Space")
|
||||||
|
|
||||||
|
def test_no_desktop_file_means_nothing_is_installed(self):
|
||||||
|
self.rc.write_text(SHORTCUTS_RC, encoding="utf-8")
|
||||||
|
self.assertIsNone(hotkey.kde_shortcut_status())
|
||||||
|
|
||||||
|
def test_a_desktop_file_with_no_entry_beside_it(self):
|
||||||
|
(self.apps / hotkey.DESKTOP_ID).write_text("[Desktop Entry]", encoding="utf-8")
|
||||||
|
self.rc.write_text("[kwin]\nOverview=Meta+W\n", encoding="utf-8")
|
||||||
|
self.assertIsNone(hotkey.kde_shortcut_status())
|
||||||
|
|
||||||
|
def test_no_shortcuts_file_at_all(self):
|
||||||
|
(self.apps / hotkey.DESKTOP_ID).write_text("[Desktop Entry]", encoding="utf-8")
|
||||||
|
self.assertIsNone(hotkey.kde_shortcut_status())
|
||||||
|
|
||||||
|
def test_a_combination_somebody_else_already_took(self):
|
||||||
|
self.rc.write_text(SHORTCUTS_RC, encoding="utf-8")
|
||||||
|
hits = hotkey.conflicting_shortcuts("Meta+W")
|
||||||
|
self.assertEqual(len(hits), 1)
|
||||||
|
self.assertIn("kwin", hits[0])
|
||||||
|
self.assertIn("Overview", hits[0])
|
||||||
|
|
||||||
|
def test_our_own_entry_is_not_a_conflict(self):
|
||||||
|
self.rc.write_text(SHORTCUTS_RC, encoding="utf-8")
|
||||||
|
self.assertEqual(hotkey.conflicting_shortcuts("Ctrl+Space"), [])
|
||||||
|
|
||||||
|
def test_a_free_combination(self):
|
||||||
|
self.rc.write_text(SHORTCUTS_RC, encoding="utf-8")
|
||||||
|
self.assertEqual(hotkey.conflicting_shortcuts("Ctrl+Alt+J"), [])
|
||||||
|
|
||||||
|
def test_a_tab_separated_entry_is_read_too(self):
|
||||||
|
self.rc.write_text(SHORTCUTS_RC, encoding="utf-8")
|
||||||
|
hits = hotkey.conflicting_shortcuts("Meta+Shift+Print")
|
||||||
|
self.assertEqual(len(hits), 1)
|
||||||
|
self.assertIn("spectacle", hits[0])
|
||||||
|
|
||||||
|
def test_no_shortcuts_file_means_no_conflicts(self):
|
||||||
|
self.assertEqual(hotkey.conflicting_shortcuts("Ctrl+Space"), [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""Translation lookup, and the table itself.
|
||||||
|
|
||||||
|
The table test is the one that matters for a pull request: a Turkish string
|
||||||
|
whose placeholder was renamed raises KeyError at the moment the message is
|
||||||
|
shown, which is exactly when nobody is watching a terminal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import string
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import i18n
|
||||||
|
from tests.support import DikteTest
|
||||||
|
|
||||||
|
|
||||||
|
def placeholders(text):
|
||||||
|
return {name for _, name, _, _ in string.Formatter().parse(text) if name}
|
||||||
|
|
||||||
|
|
||||||
|
class Resolve(unittest.TestCase):
|
||||||
|
def test_an_explicit_language_wins(self):
|
||||||
|
with mock.patch.dict("os.environ", {"LANG": "tr_TR.UTF-8"}):
|
||||||
|
self.assertEqual(i18n.resolve("en"), "en")
|
||||||
|
self.assertEqual(i18n.resolve("tr"), "tr")
|
||||||
|
|
||||||
|
def test_auto_reads_the_locale(self):
|
||||||
|
with mock.patch.dict("os.environ", {"LANG": "tr_TR.UTF-8"}, clear=True):
|
||||||
|
self.assertEqual(i18n.resolve("auto"), "tr")
|
||||||
|
with mock.patch.dict("os.environ", {"LANG": "en_GB.UTF-8"}, clear=True):
|
||||||
|
self.assertEqual(i18n.resolve("auto"), "en")
|
||||||
|
|
||||||
|
def test_lc_all_outranks_lang(self):
|
||||||
|
with mock.patch.dict("os.environ",
|
||||||
|
{"LC_ALL": "tr_TR.UTF-8", "LANG": "en_GB.UTF-8"},
|
||||||
|
clear=True):
|
||||||
|
self.assertEqual(i18n.resolve("auto"), "tr")
|
||||||
|
|
||||||
|
def test_no_locale_at_all_falls_back_to_english(self):
|
||||||
|
with mock.patch.dict("os.environ", {}, clear=True):
|
||||||
|
self.assertEqual(i18n.resolve("auto"), "en")
|
||||||
|
|
||||||
|
def test_an_unknown_code_is_not_taken_at_its_word(self):
|
||||||
|
with mock.patch.dict("os.environ", {}, clear=True):
|
||||||
|
self.assertEqual(i18n.resolve("de"), "en")
|
||||||
|
|
||||||
|
|
||||||
|
class Translate(DikteTest):
|
||||||
|
def test_english_returns_the_source_string(self):
|
||||||
|
self.assertEqual(i18n.t("Quit"), "Quit")
|
||||||
|
|
||||||
|
def test_turkish_looks_the_string_up(self):
|
||||||
|
i18n.set_language("tr")
|
||||||
|
self.assertEqual(i18n.t("Quit"), "Çık")
|
||||||
|
|
||||||
|
def test_an_untranslated_string_falls_through(self):
|
||||||
|
i18n.set_language("tr")
|
||||||
|
self.assertEqual(i18n.t("Nobody translated this"), "Nobody translated this")
|
||||||
|
|
||||||
|
def test_placeholders_are_filled_in_both_languages(self):
|
||||||
|
self.assertEqual(i18n.t("Unknown key: {key}", key="f13"), "Unknown key: f13")
|
||||||
|
i18n.set_language("tr")
|
||||||
|
self.assertIn("f13", i18n.t("Unknown key: {key}", key="f13"))
|
||||||
|
|
||||||
|
def test_a_string_with_no_arguments_is_not_formatted(self):
|
||||||
|
# Braces in the text itself must survive when nothing is passed in.
|
||||||
|
self.assertEqual(i18n.t("{not a placeholder}"), "{not a placeholder}")
|
||||||
|
|
||||||
|
def test_a_placeholder_may_be_called_anything(self):
|
||||||
|
"""Including the names of t()'s own parameters, which is why they are
|
||||||
|
positional-only: worker.py says {text}, and that has to work."""
|
||||||
|
self.assertEqual(i18n.t("Discarded: {text}", text="hello"), "Discarded: hello")
|
||||||
|
self.assertEqual(i18n.name("Claude", case="dative"), "Claude")
|
||||||
|
|
||||||
|
|
||||||
|
class Names(DikteTest):
|
||||||
|
def test_english_leaves_the_name_alone(self):
|
||||||
|
self.assertEqual(i18n.name("Claude", "dative"), "Claude")
|
||||||
|
|
||||||
|
def test_turkish_inflects_by_the_vowels_of_the_name(self):
|
||||||
|
i18n.set_language("tr")
|
||||||
|
self.assertEqual(i18n.name("Claude", "dative"), "Claude'a")
|
||||||
|
self.assertEqual(i18n.name("Codex", "dative"), "Codex'e")
|
||||||
|
self.assertEqual(i18n.name("OpenRouter", "accusative"), "OpenRouter'ı")
|
||||||
|
|
||||||
|
def test_no_case_asked_for(self):
|
||||||
|
i18n.set_language("tr")
|
||||||
|
self.assertEqual(i18n.name("Claude"), "Claude")
|
||||||
|
|
||||||
|
def test_an_unlisted_name_or_case_comes_back_unchanged(self):
|
||||||
|
i18n.set_language("tr")
|
||||||
|
self.assertEqual(i18n.name("Ollama", "dative"), "Ollama")
|
||||||
|
self.assertEqual(i18n.name("Claude", "ablative"), "Claude")
|
||||||
|
|
||||||
|
|
||||||
|
class Table(unittest.TestCase):
|
||||||
|
"""The Turkish table against the English strings it stands in for."""
|
||||||
|
|
||||||
|
def test_every_translation_keeps_the_placeholders_of_its_source(self):
|
||||||
|
for source, translated in i18n.TR.items():
|
||||||
|
with self.subTest(source=source[:50]):
|
||||||
|
self.assertEqual(
|
||||||
|
placeholders(source), placeholders(translated),
|
||||||
|
"the Turkish string does not take the same arguments",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_nothing_is_translated_to_an_empty_string(self):
|
||||||
|
for source, translated in i18n.TR.items():
|
||||||
|
with self.subTest(source=source[:50]):
|
||||||
|
self.assertTrue(translated.strip())
|
||||||
|
|
||||||
|
def test_every_translation_is_formattable(self):
|
||||||
|
"""Whatever the table holds, .format() must not blow up on it."""
|
||||||
|
for source, translated in i18n.TR.items():
|
||||||
|
names = placeholders(translated)
|
||||||
|
if not names:
|
||||||
|
continue
|
||||||
|
with self.subTest(source=source[:50]):
|
||||||
|
translated.format(**{key: "x" for key in names})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""The request a terminal sends to the running instance, and the reply it reads.
|
||||||
|
|
||||||
|
The wire format has to stay backwards compatible in both directions: a stale KDE
|
||||||
|
shortcut still sends a bare verb, and an instance from before replies existed
|
||||||
|
answers by saying nothing at all.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import ipc
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSocket:
|
||||||
|
"""QLocalSocket, as much of it as ipc.send() touches."""
|
||||||
|
|
||||||
|
def __init__(self, connected=True, reply=b""):
|
||||||
|
self.connected = connected
|
||||||
|
self.reply = reply
|
||||||
|
self.written = b""
|
||||||
|
self.server = ""
|
||||||
|
self.disconnected = False
|
||||||
|
self.read_limits = []
|
||||||
|
self._served = False
|
||||||
|
|
||||||
|
def connectToServer(self, name):
|
||||||
|
self.server = name
|
||||||
|
|
||||||
|
def waitForConnected(self, ms):
|
||||||
|
return self.connected
|
||||||
|
|
||||||
|
def write(self, data):
|
||||||
|
self.written += bytes(data)
|
||||||
|
|
||||||
|
def flush(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def waitForBytesWritten(self, ms):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def waitForReadyRead(self, ms):
|
||||||
|
self.read_limits.append(ms)
|
||||||
|
if self._served or not self.reply:
|
||||||
|
return False
|
||||||
|
self._served = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
def readAll(self):
|
||||||
|
return self.reply
|
||||||
|
|
||||||
|
def disconnectFromServer(self):
|
||||||
|
self.disconnected = True
|
||||||
|
|
||||||
|
|
||||||
|
class Paths(unittest.TestCase):
|
||||||
|
def test_script_path_points_at_dikte(self):
|
||||||
|
self.assertTrue(ipc.script_path().endswith("dikte.py"))
|
||||||
|
self.assertTrue(os.path.exists(ipc.script_path()))
|
||||||
|
|
||||||
|
def test_the_shortcut_command_runs_it_with_this_interpreter(self):
|
||||||
|
command = ipc.command_for("toggle")
|
||||||
|
self.assertTrue(command.startswith(sys.executable))
|
||||||
|
self.assertTrue(command.endswith(" toggle"))
|
||||||
|
|
||||||
|
@unittest.skipUnless(hasattr(os, "getuid"),
|
||||||
|
"the socket is named after a user id, which Windows "
|
||||||
|
"has no equivalent of")
|
||||||
|
def test_the_socket_is_per_user(self):
|
||||||
|
self.assertEqual(ipc.SERVER_NAME, f"dikte-{os.getuid()}")
|
||||||
|
|
||||||
|
|
||||||
|
class Send(unittest.TestCase):
|
||||||
|
def send(self, socket, *args, **kwargs):
|
||||||
|
with mock.patch.object(ipc, "QLocalSocket", return_value=socket):
|
||||||
|
return ipc.send(*args, **kwargs)
|
||||||
|
|
||||||
|
def written_line(self, socket):
|
||||||
|
return socket.written.decode("utf-8").strip()
|
||||||
|
|
||||||
|
def test_nothing_running(self):
|
||||||
|
sock = FakeSocket(connected=False)
|
||||||
|
self.assertIsNone(self.send(sock, "toggle"))
|
||||||
|
self.assertEqual(sock.written, b"")
|
||||||
|
|
||||||
|
def test_a_verb_on_its_own_goes_as_the_bare_word(self):
|
||||||
|
"""An older instance only understands this, and it is how updates land."""
|
||||||
|
sock = FakeSocket(reply=b'{"ok": true}\n')
|
||||||
|
self.send(sock, "restart")
|
||||||
|
self.assertEqual(self.written_line(sock), "restart")
|
||||||
|
|
||||||
|
def test_a_verb_with_arguments_goes_as_json(self):
|
||||||
|
sock = FakeSocket(reply=b'{"ok": true}\n')
|
||||||
|
self.send(sock, "ask", text="what time is it")
|
||||||
|
self.assertEqual(json.loads(self.written_line(sock)),
|
||||||
|
{"cmd": "ask", "text": "what time is it"})
|
||||||
|
|
||||||
|
def test_arguments_that_are_none_are_left_out(self):
|
||||||
|
sock = FakeSocket(reply=b'{"ok": true}\n')
|
||||||
|
self.send(sock, "record", seconds=None, paste=False)
|
||||||
|
self.assertEqual(json.loads(self.written_line(sock)),
|
||||||
|
{"cmd": "record", "paste": False})
|
||||||
|
|
||||||
|
def test_asking_to_be_waited_for_says_so(self):
|
||||||
|
sock = FakeSocket(reply=b'{"ok": true, "text": "hello"}\n')
|
||||||
|
reply = self.send(sock, "toggle", wait=True)
|
||||||
|
self.assertTrue(json.loads(self.written_line(sock))["wait"])
|
||||||
|
self.assertEqual(reply["text"], "hello")
|
||||||
|
|
||||||
|
def test_a_wait_with_no_timeout_reads_without_a_deadline(self):
|
||||||
|
sock = FakeSocket(reply=b'{"ok": true}\n')
|
||||||
|
self.send(sock, "toggle", wait=True)
|
||||||
|
self.assertEqual(sock.read_limits[0], -1)
|
||||||
|
|
||||||
|
def test_a_timeout_is_passed_on_in_milliseconds(self):
|
||||||
|
sock = FakeSocket(reply=b'{"ok": true}\n')
|
||||||
|
self.send(sock, "toggle", wait=True, timeout=2.5)
|
||||||
|
self.assertEqual(sock.read_limits[0], 2500)
|
||||||
|
|
||||||
|
def test_a_fire_and_forget_verb_does_not_wait_around(self):
|
||||||
|
sock = FakeSocket(reply=b'{"ok": true}\n')
|
||||||
|
self.send(sock, "cancel")
|
||||||
|
self.assertEqual(sock.read_limits[0], ipc.CONNECT_MS)
|
||||||
|
|
||||||
|
def test_the_reply_comes_back_as_it_was_sent(self):
|
||||||
|
sock = FakeSocket(reply=b'{"ok": false, "error": "no microphone"}\n')
|
||||||
|
self.assertEqual(self.send(sock, "toggle"),
|
||||||
|
{"ok": False, "error": "no microphone"})
|
||||||
|
|
||||||
|
def test_silence_from_an_old_instance_means_the_verb_went_through(self):
|
||||||
|
sock = FakeSocket(reply=b"")
|
||||||
|
reply = self.send(sock, "cancel")
|
||||||
|
self.assertTrue(reply["ok"])
|
||||||
|
self.assertTrue(reply["legacy"])
|
||||||
|
|
||||||
|
def test_silence_during_a_wait_is_a_failure_with_a_way_out(self):
|
||||||
|
sock = FakeSocket(reply=b"")
|
||||||
|
reply = self.send(sock, "toggle", wait=True)
|
||||||
|
self.assertFalse(reply["ok"])
|
||||||
|
self.assertIn("dikte restart", reply["error"])
|
||||||
|
|
||||||
|
def test_a_reply_that_is_not_json(self):
|
||||||
|
sock = FakeSocket(reply=b"ok\n")
|
||||||
|
self.assertEqual(self.send(sock, "toggle"), {"ok": True, "legacy": True})
|
||||||
|
|
||||||
|
def test_a_reply_that_is_json_but_not_an_object(self):
|
||||||
|
sock = FakeSocket(reply=b"[1, 2, 3]\n")
|
||||||
|
self.assertEqual(self.send(sock, "toggle"), {"ok": True, "legacy": True})
|
||||||
|
|
||||||
|
def test_the_socket_is_always_let_go_of(self):
|
||||||
|
sock = FakeSocket(reply=b'{"ok": true}\n')
|
||||||
|
self.send(sock, "toggle")
|
||||||
|
self.assertTrue(sock.disconnected)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
"""A two-channel recording turned into minutes.
|
||||||
|
|
||||||
|
Attribution is settled by the channels rather than guessed, so the tests care
|
||||||
|
about what happens at the seams: the microphone picking the other side up
|
||||||
|
through the speakers, two people talking over each other, and a run that died
|
||||||
|
after the transcription and must not pay for it twice.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import unittest
|
||||||
|
import wave
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import api
|
||||||
|
import config as cfg
|
||||||
|
import meeting
|
||||||
|
from tests.support import DikteTest, make_wav, silence, speech, stereo, tone
|
||||||
|
|
||||||
|
|
||||||
|
def seg(start, end, text, speaker):
|
||||||
|
return (start, end, text, speaker)
|
||||||
|
|
||||||
|
|
||||||
|
class SplitChannels(DikteTest):
|
||||||
|
def stereo_file(self, left, right, name="meeting.wav"):
|
||||||
|
return make_wav(self.path(name), stereo(left, right), channels=2)
|
||||||
|
|
||||||
|
def test_the_two_sides_come_out_as_separate_files(self):
|
||||||
|
path = self.stereo_file(tone(1.0, amplitude=16000), silence(1.0))
|
||||||
|
mine, theirs = meeting.split_channels(path, self.root)
|
||||||
|
for side in (mine, theirs):
|
||||||
|
with contextlib.closing(wave.open(side, "rb")) as wav:
|
||||||
|
self.assertEqual(wav.getnchannels(), 1)
|
||||||
|
self.assertEqual(wav.getnframes(), 16000)
|
||||||
|
|
||||||
|
def test_the_left_channel_is_mine(self):
|
||||||
|
path = self.stereo_file(tone(1.0, amplitude=16000), silence(1.0))
|
||||||
|
mine, theirs = meeting.split_channels(path, self.root)
|
||||||
|
self.assertGreater(max(meeting.rms_series(mine)), 0.1)
|
||||||
|
self.assertEqual(max(meeting.rms_series(theirs)), 0.0)
|
||||||
|
|
||||||
|
def test_a_recording_longer_than_the_read_block(self):
|
||||||
|
path = self.stereo_file(tone(3.0), silence(3.0))
|
||||||
|
mine, _ = meeting.split_channels(path, self.root)
|
||||||
|
with contextlib.closing(wave.open(mine, "rb")) as wav:
|
||||||
|
self.assertEqual(wav.getnframes(), 3 * 16000)
|
||||||
|
|
||||||
|
def test_a_dictation_is_not_a_meeting(self):
|
||||||
|
path = make_wav(self.path("mono.wav"), silence(1.0))
|
||||||
|
with self.assertRaises(api.ApiError):
|
||||||
|
meeting.split_channels(path, self.root)
|
||||||
|
|
||||||
|
|
||||||
|
class RmsSeries(DikteTest):
|
||||||
|
def test_silence_reads_as_nothing(self):
|
||||||
|
path = make_wav(self.path("quiet.wav"), silence(1.0))
|
||||||
|
self.assertEqual(set(meeting.rms_series(path)), {0.0})
|
||||||
|
|
||||||
|
def test_a_block_per_level_frame(self):
|
||||||
|
path = make_wav(self.path("clip.wav"), silence(1.0))
|
||||||
|
self.assertEqual(len(meeting.rms_series(path)),
|
||||||
|
-(-16000 // meeting.LEVEL_FRAMES))
|
||||||
|
|
||||||
|
def test_a_loud_recording_reads_above_zero(self):
|
||||||
|
path = make_wav(self.path("loud.wav"), tone(1.0, amplitude=16000))
|
||||||
|
self.assertGreater(max(meeting.rms_series(path)), 0.1)
|
||||||
|
|
||||||
|
def test_the_rate_is_read_off_the_file(self):
|
||||||
|
path = make_wav(self.path("clip.wav"), silence(0.1), rate=8000)
|
||||||
|
self.assertEqual(meeting.wav_rate(path), 8000)
|
||||||
|
|
||||||
|
|
||||||
|
class MergeTurns(unittest.TestCase):
|
||||||
|
def test_one_timeline_out_of_two_channels(self):
|
||||||
|
turns = meeting.merge_turns([
|
||||||
|
seg(5.0, 6.0, "and you?", "theirs"),
|
||||||
|
seg(0.0, 1.0, "hello", "mine"),
|
||||||
|
])
|
||||||
|
self.assertEqual([(start, speaker) for start, speaker, _ in turns],
|
||||||
|
[(0.0, "mine"), (5.0, "theirs")])
|
||||||
|
|
||||||
|
def test_one_person_carrying_on_stays_one_turn(self):
|
||||||
|
turns = meeting.merge_turns([
|
||||||
|
seg(0.0, 1.0, "hello", "mine"),
|
||||||
|
seg(1.2, 2.0, "how are you", "mine"),
|
||||||
|
])
|
||||||
|
self.assertEqual(len(turns), 1)
|
||||||
|
self.assertEqual(turns[0][2], "hello how are you")
|
||||||
|
|
||||||
|
def test_a_long_pause_starts_a_new_line(self):
|
||||||
|
turns = meeting.merge_turns([
|
||||||
|
seg(0.0, 1.0, "hello", "mine"),
|
||||||
|
seg(20.0, 21.0, "still there?", "mine"),
|
||||||
|
])
|
||||||
|
self.assertEqual(len(turns), 2)
|
||||||
|
|
||||||
|
def test_the_gap_is_measured_from_the_end_of_the_last_words(self):
|
||||||
|
turns = meeting.merge_turns([
|
||||||
|
seg(0.0, 10.0, "a long sentence", "mine"),
|
||||||
|
seg(15.0, 16.0, "and another", "mine"),
|
||||||
|
])
|
||||||
|
self.assertEqual(len(turns), 1)
|
||||||
|
|
||||||
|
def test_the_speaker_changing_always_starts_a_new_turn(self):
|
||||||
|
turns = meeting.merge_turns([
|
||||||
|
seg(0.0, 1.0, "hello", "mine"),
|
||||||
|
seg(1.1, 2.0, "hi", "theirs"),
|
||||||
|
])
|
||||||
|
self.assertEqual(len(turns), 2)
|
||||||
|
|
||||||
|
def test_my_microphone_hearing_them_through_the_speakers_is_dropped(self):
|
||||||
|
turns = meeting.merge_turns([
|
||||||
|
seg(0.0, 2.0, "we should ship it on Friday", "theirs"),
|
||||||
|
seg(0.1, 2.0, "we should ship it on friday", "mine"),
|
||||||
|
])
|
||||||
|
self.assertEqual(len(turns), 1)
|
||||||
|
self.assertEqual(turns[0][1], "theirs")
|
||||||
|
|
||||||
|
def test_talking_over_each_other_is_not_an_echo(self):
|
||||||
|
turns = meeting.merge_turns([
|
||||||
|
seg(0.0, 2.0, "we should ship it on Friday", "theirs"),
|
||||||
|
seg(0.1, 2.0, "no, next week is better", "mine"),
|
||||||
|
])
|
||||||
|
self.assertEqual(len(turns), 2)
|
||||||
|
|
||||||
|
def test_the_same_sentence_said_later_is_not_an_echo(self):
|
||||||
|
turns = meeting.merge_turns([
|
||||||
|
seg(0.0, 2.0, "ship it on Friday", "theirs"),
|
||||||
|
seg(30.0, 32.0, "ship it on Friday", "mine"),
|
||||||
|
])
|
||||||
|
self.assertEqual(len(turns), 2)
|
||||||
|
|
||||||
|
def test_a_side_that_transcribed_to_nothing_is_dropped(self):
|
||||||
|
turns = meeting.merge_turns([
|
||||||
|
seg(0.0, 1.0, "...", "mine"),
|
||||||
|
seg(2.0, 3.0, "hello", "theirs"),
|
||||||
|
])
|
||||||
|
self.assertEqual(len(turns), 1)
|
||||||
|
|
||||||
|
def test_nothing_was_said_at_all(self):
|
||||||
|
self.assertEqual(meeting.merge_turns([]), [])
|
||||||
|
|
||||||
|
|
||||||
|
class RenderTurns(unittest.TestCase):
|
||||||
|
def test_a_stamp_and_a_name_per_line(self):
|
||||||
|
text = meeting.render_turns(
|
||||||
|
[(0.0, "mine", "hello"), (65.0, "theirs", " hi ")], "Yusuf", "Ayşe")
|
||||||
|
self.assertEqual(text, "[00:00] Yusuf: hello\n[01:05] Ayşe: hi")
|
||||||
|
|
||||||
|
def test_nothing_to_render(self):
|
||||||
|
self.assertEqual(meeting.render_turns([], "Me", "Them"), "")
|
||||||
|
|
||||||
|
|
||||||
|
class Document(DikteTest):
|
||||||
|
def test_a_heading_becomes_the_title(self):
|
||||||
|
self.assertEqual(meeting.split_title("# Kickoff\n\nWe agreed."),
|
||||||
|
("Kickoff", "We agreed."))
|
||||||
|
|
||||||
|
def test_minutes_that_open_with_prose_have_no_title(self):
|
||||||
|
self.assertEqual(meeting.split_title("We agreed to ship."),
|
||||||
|
("", "We agreed to ship."))
|
||||||
|
|
||||||
|
def test_nothing_written(self):
|
||||||
|
self.assertEqual(meeting.split_title(""), ("", ""))
|
||||||
|
self.assertEqual(meeting.split_title(None), ("", ""))
|
||||||
|
|
||||||
|
def test_the_document_carries_the_title_the_date_and_the_length(self):
|
||||||
|
text = meeting.build_document("Kickoff", "2026-08-01 10:00", 3900,
|
||||||
|
"We agreed.", "[00:00] Me: hello")
|
||||||
|
self.assertTrue(text.startswith("# Kickoff"))
|
||||||
|
self.assertIn("2026-08-01 10:00", text)
|
||||||
|
self.assertIn("1 h 5 min", text)
|
||||||
|
|
||||||
|
def test_the_transcript_can_be_read_back_out(self):
|
||||||
|
transcript = "[00:00] Me: hello\n[00:05] Other side: hi"
|
||||||
|
text = meeting.build_document("Kickoff", "now", 60, "We agreed.", transcript)
|
||||||
|
self.assertEqual(meeting.read_transcript(text), transcript)
|
||||||
|
|
||||||
|
def test_a_document_with_no_minutes_yet_still_gives_its_transcript_back(self):
|
||||||
|
transcript = "[00:00] Me: hello"
|
||||||
|
text = meeting.build_document("Kickoff", "now", 60, "", transcript)
|
||||||
|
self.assertEqual(meeting.read_transcript(text), transcript)
|
||||||
|
|
||||||
|
def test_a_document_written_by_something_else(self):
|
||||||
|
self.assertEqual(meeting.read_transcript("# Notes\n\nJust prose."), "")
|
||||||
|
|
||||||
|
def test_the_marker_is_a_comment_so_it_never_renders(self):
|
||||||
|
self.assertTrue(meeting.TRANSCRIPT_MARKER.startswith("<!--"))
|
||||||
|
|
||||||
|
def test_the_length_reads_as_minutes_under_an_hour(self):
|
||||||
|
self.assertEqual(meeting.length_label(0), "0 min")
|
||||||
|
self.assertEqual(meeting.length_label(3540), "59 min")
|
||||||
|
|
||||||
|
def test_the_length_reads_as_hours_past_one(self):
|
||||||
|
self.assertEqual(meeting.length_label(3600), "1 h 0 min")
|
||||||
|
self.assertEqual(meeting.length_label(7325), "2 h 2 min")
|
||||||
|
|
||||||
|
def test_the_length_is_translated(self):
|
||||||
|
self.write_config({"ui_language": "tr"})
|
||||||
|
cfg.Config()
|
||||||
|
self.assertIn("dk", meeting.length_label(600))
|
||||||
|
|
||||||
|
|
||||||
|
class Entry(unittest.TestCase):
|
||||||
|
def test_the_stem_is_a_sortable_timestamp(self):
|
||||||
|
base = meeting.new_base()
|
||||||
|
self.assertEqual(len(base), 15)
|
||||||
|
self.assertEqual(base[8], "-")
|
||||||
|
|
||||||
|
def test_a_fresh_row_reads_its_date_back_out_of_the_stem(self):
|
||||||
|
entry = meeting.new_entry("20260801-143000", 125.44)
|
||||||
|
self.assertEqual(entry["base"], "20260801-143000")
|
||||||
|
self.assertEqual(entry["ts"], "2026-08-01 14:30")
|
||||||
|
self.assertEqual(entry["duration"], 125.4)
|
||||||
|
self.assertEqual(entry["status"], "recorded")
|
||||||
|
|
||||||
|
|
||||||
|
class Pipeline(DikteTest):
|
||||||
|
"""The chain, with the transcription and the two cleanup calls faked."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.conf = self.config(openrouter_api_key="sk-or-test")
|
||||||
|
self.base = "20260801-100000"
|
||||||
|
self.doc, self.wav = cfg.meeting_paths(self.base)
|
||||||
|
self.wav.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
make_wav(self.wav, stereo(speech(2.0), speech(2.0, freq=220.0)), channels=2)
|
||||||
|
cfg.save_meeting(meeting.new_entry(self.base, 1.0))
|
||||||
|
|
||||||
|
def run_pipeline(self, entry=None, segments=None, minutes="# Kickoff\n\nAgreed.",
|
||||||
|
cleanup_fails=False):
|
||||||
|
worker = meeting.MeetingPipeline(self.conf)
|
||||||
|
done, failures = [], []
|
||||||
|
worker.finished.connect(lambda *args: done.append(args))
|
||||||
|
worker.failed.connect(lambda *args: failures.append(args))
|
||||||
|
|
||||||
|
def cleanup(text, *args, **kwargs):
|
||||||
|
if cleanup_fails:
|
||||||
|
raise api.ApiError("OpenRouter is rate limiting you")
|
||||||
|
return minutes
|
||||||
|
|
||||||
|
with mock.patch.object(api, "transcribe_segments",
|
||||||
|
return_value=segments or [(0.0, 1.0, "hello")]), \
|
||||||
|
mock.patch.object(api, "cleanup", side_effect=cleanup):
|
||||||
|
# Run in this thread: the signals would otherwise be queued and
|
||||||
|
# never delivered without an event loop.
|
||||||
|
worker._work(entry or cfg.read_meetings()[0])
|
||||||
|
return done, failures
|
||||||
|
|
||||||
|
def test_a_recording_becomes_a_document(self):
|
||||||
|
done, failures = self.run_pipeline()
|
||||||
|
self.assertEqual(failures, [])
|
||||||
|
self.assertEqual(done[0], (self.base, "Kickoff"))
|
||||||
|
self.assertIn("Agreed.", self.doc.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
def test_the_row_ends_up_done_with_its_title(self):
|
||||||
|
self.run_pipeline()
|
||||||
|
row = cfg.read_meetings()[0]
|
||||||
|
self.assertEqual(row["status"], "done")
|
||||||
|
self.assertEqual(row["title"], "Kickoff")
|
||||||
|
|
||||||
|
def test_the_audio_goes_once_the_minutes_are_written(self):
|
||||||
|
self.run_pipeline()
|
||||||
|
self.assertFalse(self.wav.exists())
|
||||||
|
|
||||||
|
def test_the_audio_is_kept_when_the_setting_says_so(self):
|
||||||
|
self.conf["meeting_keep_audio"] = True
|
||||||
|
self.run_pipeline()
|
||||||
|
self.assertTrue(self.wav.exists())
|
||||||
|
|
||||||
|
def test_a_failed_run_keeps_the_audio_whatever_the_setting_says(self):
|
||||||
|
"""It is the only copy of the meeting, and a retry starts from it."""
|
||||||
|
_, failures = self.run_pipeline(cleanup_fails=True)
|
||||||
|
self.assertTrue(self.wav.exists())
|
||||||
|
self.assertEqual(failures[0][0], self.base)
|
||||||
|
self.assertEqual(cfg.read_meetings()[0]["status"], "failed")
|
||||||
|
|
||||||
|
def test_a_retry_does_not_transcribe_the_hour_again(self):
|
||||||
|
self.conf["meeting_cleanup"] = False
|
||||||
|
self.run_pipeline(cleanup_fails=True)
|
||||||
|
entry = cfg.read_meetings()[0]
|
||||||
|
self.assertEqual(entry["status"], "failed")
|
||||||
|
|
||||||
|
with mock.patch.object(api, "transcribe_segments") as transcribe, \
|
||||||
|
mock.patch.object(api, "cleanup", return_value="# Kickoff\n\nAgreed."):
|
||||||
|
worker = meeting.MeetingPipeline(self.conf)
|
||||||
|
worker._work(dict(entry, status="transcribed"))
|
||||||
|
transcribe.assert_not_called()
|
||||||
|
self.assertEqual(cfg.read_meetings()[0]["status"], "done")
|
||||||
|
|
||||||
|
def test_a_recording_that_is_gone(self):
|
||||||
|
self.wav.unlink()
|
||||||
|
_, failures = self.run_pipeline()
|
||||||
|
self.assertIn("gone", failures[0][1])
|
||||||
|
|
||||||
|
def test_a_meeting_where_nobody_said_anything(self):
|
||||||
|
with mock.patch.object(meeting.MeetingPipeline, "_silent",
|
||||||
|
return_value=True):
|
||||||
|
_, failures = self.run_pipeline()
|
||||||
|
self.assertIn("speech", failures[0][1])
|
||||||
|
|
||||||
|
def test_the_transcript_is_attributed_by_channel(self):
|
||||||
|
self.conf["meeting_self_name"] = "Yusuf"
|
||||||
|
self.conf["meeting_other_name"] = "Ayşe"
|
||||||
|
self.conf["meeting_cleanup"] = False
|
||||||
|
sides = iter([[(0.0, 1.0, "shall we ship it")],
|
||||||
|
[(2.0, 3.0, "next week is better")]])
|
||||||
|
with mock.patch.object(api, "transcribe_segments",
|
||||||
|
side_effect=lambda *a, **k: next(sides)), \
|
||||||
|
mock.patch.object(api, "cleanup", return_value="# Kickoff\n\nAgreed."):
|
||||||
|
meeting.MeetingPipeline(self.conf)._work(cfg.read_meetings()[0])
|
||||||
|
transcript = meeting.read_transcript(self.doc.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(transcript.splitlines(),
|
||||||
|
["[00:00] Yusuf: shall we ship it",
|
||||||
|
"[00:02] Ayşe: next week is better"])
|
||||||
|
|
||||||
|
def test_minutes_with_no_heading_fall_back_to_a_title(self):
|
||||||
|
self.run_pipeline(minutes="We agreed to ship.")
|
||||||
|
self.assertEqual(cfg.read_meetings()[0]["title"], "Meeting")
|
||||||
|
|
||||||
|
def test_a_second_run_while_one_is_going_is_refused(self):
|
||||||
|
worker = meeting.MeetingPipeline(self.conf)
|
||||||
|
worker._thread = mock.Mock(is_alive=lambda: True)
|
||||||
|
self.assertFalse(worker.run({"base": self.base}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""The clipboard and the key press, which is where a dictation actually lands.
|
||||||
|
|
||||||
|
Everything here shells out, so the tools are faked. What the tests hold onto is
|
||||||
|
the command line: a paste that presses the wrong codes, or in the wrong order,
|
||||||
|
types nothing and looks like a hang.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import paste
|
||||||
|
from tests.support import DikteTest, FakeCompleted, linux_only, only_these_tools
|
||||||
|
|
||||||
|
|
||||||
|
@linux_only
|
||||||
|
class ReadClipboard(DikteTest):
|
||||||
|
def test_no_wl_paste_installed(self):
|
||||||
|
with only_these_tools():
|
||||||
|
self.assertIsNone(paste.read_clipboard())
|
||||||
|
|
||||||
|
def test_what_is_on_the_clipboard_comes_back_as_bytes(self):
|
||||||
|
with only_these_tools("wl-paste"), \
|
||||||
|
mock.patch.object(subprocess, "run",
|
||||||
|
return_value=FakeCompleted(stdout=b"hello")) as run:
|
||||||
|
self.assertEqual(paste.read_clipboard(), b"hello")
|
||||||
|
self.assertEqual(run.call_args.args[0], ["wl-paste", "--no-newline"])
|
||||||
|
|
||||||
|
def test_an_empty_clipboard_is_not_an_error(self):
|
||||||
|
with only_these_tools("wl-paste"), \
|
||||||
|
mock.patch.object(subprocess, "run",
|
||||||
|
return_value=FakeCompleted(returncode=1)):
|
||||||
|
self.assertIsNone(paste.read_clipboard())
|
||||||
|
|
||||||
|
def test_a_tool_that_will_not_run(self):
|
||||||
|
with only_these_tools("wl-paste"), \
|
||||||
|
mock.patch.object(subprocess, "run", side_effect=OSError("nope")):
|
||||||
|
self.assertIsNone(paste.read_clipboard())
|
||||||
|
|
||||||
|
|
||||||
|
@linux_only
|
||||||
|
class Copy(DikteTest):
|
||||||
|
def test_no_wl_copy_installed(self):
|
||||||
|
with only_these_tools(), self.assertRaises(paste.PasteError) as caught:
|
||||||
|
paste.copy("hello")
|
||||||
|
self.assertIn("wl-clipboard", str(caught.exception))
|
||||||
|
|
||||||
|
def test_the_text_goes_in_as_utf8(self):
|
||||||
|
with only_these_tools("wl-copy"), \
|
||||||
|
mock.patch.object(subprocess, "run",
|
||||||
|
return_value=FakeCompleted()) as run:
|
||||||
|
paste.copy("günaydın")
|
||||||
|
self.assertEqual(run.call_args.kwargs["input"], "günaydın".encode())
|
||||||
|
|
||||||
|
def test_the_pipes_are_closed_so_the_call_can_return(self):
|
||||||
|
"""wl-copy forks and holds the selection; a pipe nobody drains hangs."""
|
||||||
|
with only_these_tools("wl-copy"), \
|
||||||
|
mock.patch.object(subprocess, "run",
|
||||||
|
return_value=FakeCompleted()) as run:
|
||||||
|
paste.copy("hello")
|
||||||
|
self.assertEqual(run.call_args.kwargs["stdout"], subprocess.DEVNULL)
|
||||||
|
self.assertEqual(run.call_args.kwargs["stderr"], subprocess.DEVNULL)
|
||||||
|
|
||||||
|
def test_a_non_zero_exit_is_reported(self):
|
||||||
|
with only_these_tools("wl-copy"), \
|
||||||
|
mock.patch.object(subprocess, "run",
|
||||||
|
return_value=FakeCompleted(returncode=1)), \
|
||||||
|
self.assertRaises(paste.PasteError):
|
||||||
|
paste.copy("hello")
|
||||||
|
|
||||||
|
def test_a_tool_that_will_not_run(self):
|
||||||
|
with only_these_tools("wl-copy"), \
|
||||||
|
mock.patch.object(subprocess, "run", side_effect=OSError("nope")), \
|
||||||
|
self.assertRaises(paste.PasteError):
|
||||||
|
paste.copy("hello")
|
||||||
|
|
||||||
|
|
||||||
|
@linux_only
|
||||||
|
class CopyBytes(DikteTest):
|
||||||
|
def test_nothing_to_restore(self):
|
||||||
|
with only_these_tools("wl-copy"), \
|
||||||
|
mock.patch.object(subprocess, "run") as run:
|
||||||
|
paste.copy_bytes(None)
|
||||||
|
run.assert_not_called()
|
||||||
|
|
||||||
|
def test_restoring_never_raises(self):
|
||||||
|
"""It runs after the paste went in; failing here must not undo that."""
|
||||||
|
with only_these_tools("wl-copy"), \
|
||||||
|
mock.patch.object(subprocess, "run", side_effect=OSError("nope")):
|
||||||
|
paste.copy_bytes(b"whatever was there before")
|
||||||
|
|
||||||
|
def test_the_bytes_go_back_untouched(self):
|
||||||
|
with only_these_tools("wl-copy"), \
|
||||||
|
mock.patch.object(subprocess, "run",
|
||||||
|
return_value=FakeCompleted()) as run:
|
||||||
|
paste.copy_bytes(b"\x89PNG\r\n")
|
||||||
|
self.assertEqual(run.call_args.kwargs["input"], b"\x89PNG\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
@linux_only
|
||||||
|
class Press(DikteTest):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
# The settle delay is real time nobody needs to spend in a test.
|
||||||
|
self.patch_attr(paste.time, "sleep", lambda seconds: None)
|
||||||
|
|
||||||
|
def run_press(self, shortcut, result=None):
|
||||||
|
with only_these_tools("ydotool"), \
|
||||||
|
mock.patch.object(subprocess, "run",
|
||||||
|
return_value=result or FakeCompleted()) as run:
|
||||||
|
paste.press(shortcut)
|
||||||
|
return run.call_args.args[0]
|
||||||
|
|
||||||
|
def test_no_ydotool_installed(self):
|
||||||
|
with only_these_tools():
|
||||||
|
self.assertFalse(paste.ydotool_ready())
|
||||||
|
with self.assertRaises(paste.PasteError):
|
||||||
|
paste.press()
|
||||||
|
|
||||||
|
def test_ctrl_v_presses_down_then_lets_go_in_reverse(self):
|
||||||
|
self.assertEqual(self.run_press("ctrl+v"),
|
||||||
|
["ydotool", "key", "29:1", "47:1", "47:0", "29:0"])
|
||||||
|
|
||||||
|
def test_three_keys(self):
|
||||||
|
self.assertEqual(self.run_press("ctrl+shift+v"),
|
||||||
|
["ydotool", "key", "29:1", "42:1", "47:1",
|
||||||
|
"47:0", "42:0", "29:0"])
|
||||||
|
|
||||||
|
def test_case_and_spacing_do_not_matter(self):
|
||||||
|
self.assertEqual(self.run_press(" Ctrl + V "), self.run_press("ctrl+v"))
|
||||||
|
|
||||||
|
def test_the_synonyms_land_on_the_same_codes(self):
|
||||||
|
self.assertEqual(self.run_press("control+insert"),
|
||||||
|
["ydotool", "key", "29:1", "110:1", "110:0", "29:0"])
|
||||||
|
self.assertEqual(self.run_press("super+enter"), self.run_press("meta+return"))
|
||||||
|
|
||||||
|
def test_a_key_nobody_mapped(self):
|
||||||
|
with only_these_tools("ydotool"), mock.patch.object(subprocess, "run"), \
|
||||||
|
self.assertRaises(paste.PasteError) as caught:
|
||||||
|
paste.press("ctrl+f13")
|
||||||
|
self.assertIn("f13", str(caught.exception))
|
||||||
|
|
||||||
|
def test_ydotoold_not_running_says_so(self):
|
||||||
|
with self.assertRaises(paste.PasteError) as caught:
|
||||||
|
self.run_press("ctrl+v", FakeCompleted(returncode=1, stderr="no socket"))
|
||||||
|
self.assertIn("ydotoold", str(caught.exception))
|
||||||
|
|
||||||
|
def test_a_tool_that_will_not_run(self):
|
||||||
|
with only_these_tools("ydotool"), \
|
||||||
|
mock.patch.object(subprocess, "run", side_effect=OSError("nope")), \
|
||||||
|
self.assertRaises(paste.PasteError):
|
||||||
|
paste.press("ctrl+v")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
"""The two windows, on Qt's offscreen platform.
|
||||||
|
|
||||||
|
Not a look at the pixels: what these hold onto is the round trip through the
|
||||||
|
settings window. Every tab loads a setting into a widget and writes it back on
|
||||||
|
save, so a setting added to one half and not the other is silently reset the
|
||||||
|
next time anybody presses Save. That is the failure this catches.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import QApplication, QMessageBox
|
||||||
|
|
||||||
|
import config as cfg
|
||||||
|
import overlay as overlay_module
|
||||||
|
import settings_ui
|
||||||
|
from tests.support import DikteTest, only_these_tools
|
||||||
|
|
||||||
|
# One application for the whole run; Qt allows no second one.
|
||||||
|
_app = QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
# A valid non-default value for every setting the window shows. Anything the
|
||||||
|
# window does not touch is left out: the round trip cannot lose what it never
|
||||||
|
# reads.
|
||||||
|
CHANGED = {
|
||||||
|
"ui_language": "tr",
|
||||||
|
"language": "tr",
|
||||||
|
"auto_paste": False,
|
||||||
|
"paste_shortcut": "ctrl+shift+v",
|
||||||
|
"restore_clipboard": True,
|
||||||
|
"overlay_corner": "top-right",
|
||||||
|
"max_seconds": 120,
|
||||||
|
"skip_silent": False,
|
||||||
|
"silence_db": -42.0,
|
||||||
|
"filter_hallucinations": False,
|
||||||
|
"keep_audio": True,
|
||||||
|
"openai_api_key": "sk-test-key",
|
||||||
|
"openrouter_api_key": "sk-or-test-key",
|
||||||
|
"transcribe_provider": "openrouter",
|
||||||
|
"transcribe_model": "whisper-1",
|
||||||
|
"openrouter_transcribe_model": "openai/whisper-1",
|
||||||
|
"cleanup_enabled": False,
|
||||||
|
"cleanup_model": "some/other-model",
|
||||||
|
"cleanup_reasoning": "high",
|
||||||
|
"cleanup_prompt": "Only fix the punctuation.",
|
||||||
|
"file_cleanup_prompt": "Keep the stamps where they are.",
|
||||||
|
"transcribe_prompt": "Paraşüt, OpenFrame",
|
||||||
|
"assistant_provider": "codex",
|
||||||
|
"assistant_model": "opus",
|
||||||
|
"assistant_permission_mode": "manual",
|
||||||
|
"assistant_codex_model": "gpt-5",
|
||||||
|
"assistant_codex_sandbox": "read-only",
|
||||||
|
"assistant_openrouter_model": "some/agent-model",
|
||||||
|
"assistant_reasoning": "high",
|
||||||
|
"assistant_dir": "/tmp",
|
||||||
|
"assistant_timeout": 600,
|
||||||
|
"assistant_session_minutes": 90,
|
||||||
|
"assistant_paste": False,
|
||||||
|
"assistant_cleanup": True,
|
||||||
|
"assistant_prompt": "Answer in one sentence.",
|
||||||
|
"assistant_shortcut": "Meta+A",
|
||||||
|
"meeting_self_name": "Yusuf",
|
||||||
|
"meeting_other_name": "Ayşe",
|
||||||
|
"meeting_participants": "Mehmet",
|
||||||
|
"meeting_model": "some/meeting-model",
|
||||||
|
"meeting_reasoning": "medium",
|
||||||
|
"meeting_language": "tr",
|
||||||
|
"meeting_cleanup": False,
|
||||||
|
"meeting_max_seconds": 7200,
|
||||||
|
"meeting_keep_audio": True,
|
||||||
|
"meeting_shortcut": "Meta+M",
|
||||||
|
"meeting_prompt": "Write it as bullet points.",
|
||||||
|
"file_timestamps": True,
|
||||||
|
"file_cleanup": False,
|
||||||
|
"shortcut": "Ctrl+Alt+Space",
|
||||||
|
"evdev_hotkey": True,
|
||||||
|
"history_limit": 50,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(DikteTest):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
# No pactl, no model lists over the network, and no modal dialogue
|
||||||
|
# waiting for somebody to press OK.
|
||||||
|
self.enterContext(only_these_tools())
|
||||||
|
self.enterContext(mock.patch.object(QMessageBox, "information"))
|
||||||
|
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||||
|
"_load_models"))
|
||||||
|
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||||
|
"_load_transcribe_models"))
|
||||||
|
self.enterContext(mock.patch.object(settings_ui.hotkey, "APPLICATIONS_DIR",
|
||||||
|
self.path("applications")))
|
||||||
|
self.enterContext(mock.patch.object(settings_ui.hotkey, "SHORTCUTS_FILE",
|
||||||
|
self.path("kglobalshortcutsrc")))
|
||||||
|
|
||||||
|
def window(self, conf):
|
||||||
|
window = settings_ui.SettingsWindow(conf, "dikte toggle")
|
||||||
|
self.addCleanup(window.deleteLater)
|
||||||
|
self.addCleanup(window.close)
|
||||||
|
return window
|
||||||
|
|
||||||
|
def test_the_window_opens_with_every_tab_on_it(self):
|
||||||
|
window = self.window(cfg.Config())
|
||||||
|
tabs = window.findChildren(settings_ui.QTabWidget)[0]
|
||||||
|
self.assertEqual(tabs.count(), 9)
|
||||||
|
self.assertEqual(window.windowTitle(), "Dikte Settings")
|
||||||
|
|
||||||
|
def test_saving_without_touching_anything_changes_nothing(self):
|
||||||
|
"""Every widget has to load what is stored, or Save writes its default
|
||||||
|
over it. This says so for the whole table at once."""
|
||||||
|
conf = cfg.Config()
|
||||||
|
before = dict(conf.data)
|
||||||
|
self.window(conf)._save()
|
||||||
|
self.assertEqual(conf.data, before)
|
||||||
|
|
||||||
|
def test_a_setting_of_your_own_survives_the_round_trip(self):
|
||||||
|
self.write_config(CHANGED)
|
||||||
|
conf = cfg.Config()
|
||||||
|
self.window(conf)._save()
|
||||||
|
stored = self.read_config_file()
|
||||||
|
for key, value in CHANGED.items():
|
||||||
|
with self.subTest(key=key):
|
||||||
|
self.assertEqual(stored[key], value)
|
||||||
|
|
||||||
|
def test_the_settings_the_window_does_not_show_are_left_alone(self):
|
||||||
|
"""A tab nobody wrote must not reset what the command line set."""
|
||||||
|
self.write_config({"silence_db": -42.0, "speech_margin_db": 15.0,
|
||||||
|
"openrouter_base_url": "http://localhost:1234/v1"})
|
||||||
|
conf = cfg.Config()
|
||||||
|
self.window(conf)._save()
|
||||||
|
stored = self.read_config_file()
|
||||||
|
self.assertEqual(stored["speech_margin_db"], 15.0)
|
||||||
|
self.assertEqual(stored["openrouter_base_url"], "http://localhost:1234/v1")
|
||||||
|
|
||||||
|
def test_a_prompt_left_at_its_default_is_stored_as_empty(self):
|
||||||
|
"""So that switching the interface language switches the prompt too."""
|
||||||
|
conf = cfg.Config()
|
||||||
|
self.window(conf)._save()
|
||||||
|
self.assertEqual(conf["cleanup_prompt"], "")
|
||||||
|
self.assertEqual(conf["meeting_prompt"], "")
|
||||||
|
self.assertEqual(conf["assistant_prompt"], "")
|
||||||
|
|
||||||
|
def test_each_provider_keeps_its_own_transcription_model(self):
|
||||||
|
self.write_config({"transcribe_provider": "openai",
|
||||||
|
"transcribe_model": "gpt-4o-transcribe",
|
||||||
|
"openrouter_transcribe_model": "openai/whisper-1"})
|
||||||
|
conf = cfg.Config()
|
||||||
|
window = self.window(conf)
|
||||||
|
window.transcribe_provider.setCurrentIndex(
|
||||||
|
window.transcribe_provider.findData("openrouter"))
|
||||||
|
window._save()
|
||||||
|
self.assertEqual(conf["transcribe_provider"], "openrouter")
|
||||||
|
self.assertEqual(conf["transcribe_model"], "gpt-4o-transcribe")
|
||||||
|
|
||||||
|
def test_saving_applies_the_lowered_history_limit_at_once(self):
|
||||||
|
for index in range(10):
|
||||||
|
cfg.append_history({"ts": "now", "text": str(index)})
|
||||||
|
self.write_config({"history_limit": 3})
|
||||||
|
self.window(cfg.Config())._save()
|
||||||
|
self.assertEqual(len(cfg.read_history()), 3)
|
||||||
|
|
||||||
|
def test_saving_tells_whoever_is_listening(self):
|
||||||
|
conf = cfg.Config()
|
||||||
|
window = self.window(conf)
|
||||||
|
applied = []
|
||||||
|
window.applied.connect(lambda: applied.append(True))
|
||||||
|
window._save()
|
||||||
|
self.assertEqual(applied, [True])
|
||||||
|
|
||||||
|
def test_the_window_is_readable_in_turkish_too(self):
|
||||||
|
self.write_config({"ui_language": "tr"})
|
||||||
|
window = self.window(cfg.Config())
|
||||||
|
self.assertEqual(window.windowTitle(), "Dikte Ayarları")
|
||||||
|
|
||||||
|
|
||||||
|
class Overlay(DikteTest):
|
||||||
|
def overlay(self, **kwargs):
|
||||||
|
widget = overlay_module.Overlay(**kwargs)
|
||||||
|
self.addCleanup(widget.deleteLater)
|
||||||
|
self.addCleanup(widget.close)
|
||||||
|
return widget
|
||||||
|
|
||||||
|
def test_it_never_takes_focus(self):
|
||||||
|
"""It appears while you are typing; stealing the keyboard would be rude."""
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
flags = self.overlay().windowFlags()
|
||||||
|
self.assertTrue(flags & Qt.WindowType.WindowDoesNotAcceptFocus)
|
||||||
|
self.assertTrue(flags & Qt.WindowType.WindowStaysOnTopHint)
|
||||||
|
|
||||||
|
def test_recording_then_working_then_done(self):
|
||||||
|
widget = self.overlay()
|
||||||
|
widget.show_recording()
|
||||||
|
self.assertTrue(widget.showing)
|
||||||
|
widget.push_level(0.5)
|
||||||
|
widget.set_seconds(3)
|
||||||
|
widget.show_busy("Transcribing…")
|
||||||
|
widget.show_done("Pasted")
|
||||||
|
widget._conceal()
|
||||||
|
self.assertFalse(widget.showing)
|
||||||
|
|
||||||
|
def test_a_meeting_shows_both_sides(self):
|
||||||
|
widget = self.overlay()
|
||||||
|
widget.show_meeting()
|
||||||
|
widget.push_levels(0.4, 0.7)
|
||||||
|
self.assertTrue(widget.showing)
|
||||||
|
|
||||||
|
def test_every_corner_is_understood(self):
|
||||||
|
for corner in ("top-left", "top-right", "bottom-left", "bottom-right"):
|
||||||
|
with self.subTest(corner=corner):
|
||||||
|
widget = self.overlay(corner=corner)
|
||||||
|
widget.show_recording()
|
||||||
|
widget._reposition()
|
||||||
|
|
||||||
|
def test_a_warning_and_an_error_both_show(self):
|
||||||
|
widget = self.overlay()
|
||||||
|
widget.show_warning("cleanup failed")
|
||||||
|
widget.show_error("no microphone")
|
||||||
|
|
||||||
|
def test_one_indicator_can_stack_above_another(self):
|
||||||
|
first = self.overlay()
|
||||||
|
first.show_recording()
|
||||||
|
second = self.overlay(below=first)
|
||||||
|
second.show_busy("Asking Claude…")
|
||||||
|
self.assertTrue(second.showing)
|
||||||
|
|
||||||
|
def test_a_job_in_progress_can_be_waved_away(self):
|
||||||
|
"""Ten minutes of work should not have to be watched for ten minutes."""
|
||||||
|
widget = self.overlay(dismissable=True)
|
||||||
|
widget.show_busy("Asking Claude…")
|
||||||
|
widget.dismiss()
|
||||||
|
self.assertFalse(widget.showing)
|
||||||
|
|
||||||
|
def test_progress_stays_away_once_it_was_waved_off(self):
|
||||||
|
widget = self.overlay(dismissable=True)
|
||||||
|
widget.show_busy("Asking Claude…")
|
||||||
|
widget.muted = True
|
||||||
|
widget.dismiss()
|
||||||
|
widget.show_busy("Reading a web page…")
|
||||||
|
self.assertFalse(widget.showing)
|
||||||
|
|
||||||
|
def test_the_outcome_shows_even_so(self):
|
||||||
|
"""Waving it away asks not to be watched, not to be kept in the dark."""
|
||||||
|
widget = self.overlay(dismissable=True)
|
||||||
|
widget.show_busy("Asking Claude…")
|
||||||
|
widget.muted = True
|
||||||
|
widget.dismiss()
|
||||||
|
widget.show_done("Pasted")
|
||||||
|
self.assertTrue(widget.showing)
|
||||||
|
|
||||||
|
def test_a_new_run_starts_visible_whatever_the_last_one_did(self):
|
||||||
|
widget = self.overlay(dismissable=True)
|
||||||
|
widget.muted = True
|
||||||
|
widget.show_recording()
|
||||||
|
self.assertTrue(widget.showing)
|
||||||
|
self.assertFalse(widget.muted)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""The silence check, which decides whether a recording is worth an API call."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import vad
|
||||||
|
from tests.support import DikteTest
|
||||||
|
|
||||||
|
CHUNK = 1024 / 16000 # what worker.py feeds it: one chunk of the level meter
|
||||||
|
|
||||||
|
|
||||||
|
def levels(*, quiet=0.0005, loud=0.2, quiet_chunks=40, loud_chunks=20):
|
||||||
|
"""A recording as its per-chunk RMS values: a noise floor, then speech."""
|
||||||
|
return [quiet] * quiet_chunks + [loud] * loud_chunks
|
||||||
|
|
||||||
|
|
||||||
|
class ToDb(unittest.TestCase):
|
||||||
|
def test_silence_does_not_take_the_logarithm_of_zero(self):
|
||||||
|
self.assertEqual(vad.to_db(0.0), -120.0)
|
||||||
|
self.assertEqual(vad.to_db(-1.0), -120.0)
|
||||||
|
|
||||||
|
def test_full_scale_is_zero_db(self):
|
||||||
|
self.assertEqual(vad.to_db(1.0), 0.0)
|
||||||
|
|
||||||
|
def test_half_scale_is_about_minus_six(self):
|
||||||
|
self.assertAlmostEqual(vad.to_db(0.5), -6.02, places=2)
|
||||||
|
|
||||||
|
|
||||||
|
class Percentile(unittest.TestCase):
|
||||||
|
def test_empty(self):
|
||||||
|
self.assertEqual(vad._percentile([], 0.5), 0.0)
|
||||||
|
|
||||||
|
def test_does_not_run_off_the_end(self):
|
||||||
|
self.assertEqual(vad._percentile([1, 2, 3], 1.0), 3)
|
||||||
|
|
||||||
|
def test_picks_by_position(self):
|
||||||
|
self.assertEqual(vad._percentile([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 0.1), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class Analyse(unittest.TestCase):
|
||||||
|
def test_no_chunks_at_all(self):
|
||||||
|
stats = vad.analyse([], CHUNK)
|
||||||
|
self.assertEqual(stats["voiced_seconds"], 0.0)
|
||||||
|
self.assertEqual(stats["dynamic_db"], 0.0)
|
||||||
|
self.assertEqual(stats["speech_db"], -120.0)
|
||||||
|
|
||||||
|
def test_speech_rises_above_the_floor(self):
|
||||||
|
stats = vad.analyse(levels(), CHUNK)
|
||||||
|
self.assertGreater(stats["speech_db"], stats["noise_db"])
|
||||||
|
self.assertGreater(stats["dynamic_db"], 10.0)
|
||||||
|
self.assertGreater(stats["voiced_seconds"], 0.3)
|
||||||
|
|
||||||
|
def test_a_flat_recording_has_no_dynamics_and_no_voice(self):
|
||||||
|
stats = vad.analyse([0.02] * 60, CHUNK)
|
||||||
|
self.assertEqual(stats["dynamic_db"], 0.0)
|
||||||
|
self.assertEqual(stats["voiced_seconds"], 0.0)
|
||||||
|
|
||||||
|
def test_voiced_seconds_follow_the_chunk_length(self):
|
||||||
|
short = vad.analyse(levels(), CHUNK)
|
||||||
|
long = vad.analyse(levels(), CHUNK * 2)
|
||||||
|
self.assertAlmostEqual(long["voiced_seconds"], short["voiced_seconds"] * 2)
|
||||||
|
|
||||||
|
def test_a_wider_margin_counts_fewer_chunks_as_voice(self):
|
||||||
|
wide = vad.analyse(levels(loud=0.005), CHUNK, margin_db=40.0)
|
||||||
|
narrow = vad.analyse(levels(loud=0.005), CHUNK, margin_db=3.0)
|
||||||
|
self.assertLess(wide["voiced_seconds"], narrow["voiced_seconds"])
|
||||||
|
|
||||||
|
|
||||||
|
class IsSilent(unittest.TestCase):
|
||||||
|
def test_speech_passes(self):
|
||||||
|
self.assertFalse(vad.is_silent(vad.analyse(levels(), CHUNK)))
|
||||||
|
|
||||||
|
def test_everything_below_the_absolute_floor(self):
|
||||||
|
stats = vad.analyse([0.00001] * 60, CHUNK)
|
||||||
|
self.assertTrue(vad.is_silent(stats))
|
||||||
|
|
||||||
|
def test_a_single_loud_chunk_is_not_long_enough_to_be_a_word(self):
|
||||||
|
stats = vad.analyse(levels(loud_chunks=1), CHUNK)
|
||||||
|
self.assertTrue(vad.is_silent(stats))
|
||||||
|
self.assertLess(stats["voiced_seconds"], 0.3)
|
||||||
|
|
||||||
|
def test_steady_hiss_near_the_floor(self):
|
||||||
|
# Loud enough to clear the absolute floor, but the level never moves,
|
||||||
|
# which is a fan rather than a voice.
|
||||||
|
stats = vad.analyse([0.0025] * 60, CHUNK)
|
||||||
|
self.assertGreater(stats["speech_db"], -55.0)
|
||||||
|
self.assertTrue(vad.is_silent(stats))
|
||||||
|
|
||||||
|
def test_a_level_that_never_moves_is_never_speech_however_loud(self):
|
||||||
|
"""The floor is the whole recording, so nothing can rise above it.
|
||||||
|
|
||||||
|
This is what settles a flat recording, at any volume: the margin rule
|
||||||
|
gets there before the dynamics rule ever does.
|
||||||
|
"""
|
||||||
|
stats = vad.analyse([0.25] * 60, CHUNK)
|
||||||
|
self.assertEqual(stats["voiced_seconds"], 0.0)
|
||||||
|
self.assertTrue(vad.is_silent(stats))
|
||||||
|
|
||||||
|
def test_flat_dynamics_alone_do_not_reject_a_loud_recording(self):
|
||||||
|
stats = {"speech_db": -20.0, "noise_db": -24.0,
|
||||||
|
"dynamic_db": 4.0, "voiced_seconds": 1.0}
|
||||||
|
self.assertFalse(vad.is_silent(stats))
|
||||||
|
|
||||||
|
def test_flat_dynamics_do_reject_one_sitting_near_the_floor(self):
|
||||||
|
stats = {"speech_db": -50.0, "noise_db": -54.0,
|
||||||
|
"dynamic_db": 4.0, "voiced_seconds": 1.0}
|
||||||
|
self.assertTrue(vad.is_silent(stats))
|
||||||
|
|
||||||
|
def test_the_thresholds_are_honoured(self):
|
||||||
|
stats = vad.analyse(levels(), CHUNK)
|
||||||
|
self.assertTrue(vad.is_silent(stats, silence_db=-1.0))
|
||||||
|
self.assertTrue(vad.is_silent(stats, min_voiced_seconds=999.0))
|
||||||
|
|
||||||
|
|
||||||
|
class Hallucinations(DikteTest):
|
||||||
|
def test_a_stock_phrase_from_a_short_clip(self):
|
||||||
|
self.assertTrue(vad.looks_like_hallucination("Altyazı M.K.", 2.0))
|
||||||
|
self.assertTrue(vad.looks_like_hallucination("Thanks for watching!", 2.0))
|
||||||
|
|
||||||
|
def test_the_same_phrase_repeated(self):
|
||||||
|
self.assertTrue(vad.looks_like_hallucination(
|
||||||
|
"Altyazı M.K. Altyazı M.K. Altyazı M.K.", 3.0))
|
||||||
|
|
||||||
|
def test_a_long_clip_is_believed(self):
|
||||||
|
# Somebody who talks for half a minute and lands on the phrase meant it.
|
||||||
|
self.assertFalse(vad.looks_like_hallucination("Thanks for watching", 30.0))
|
||||||
|
|
||||||
|
def test_real_speech_is_kept(self):
|
||||||
|
self.assertFalse(vad.looks_like_hallucination("Bugün toplantı var.", 2.0))
|
||||||
|
self.assertFalse(vad.looks_like_hallucination("Send it on Thursday.", 2.0))
|
||||||
|
|
||||||
|
def test_an_empty_transcript_counts_as_invented(self):
|
||||||
|
self.assertTrue(vad.looks_like_hallucination(" ", 2.0))
|
||||||
|
self.assertTrue(vad.looks_like_hallucination("...", 2.0))
|
||||||
|
|
||||||
|
def test_matching_ignores_case_punctuation_and_turkish_letters(self):
|
||||||
|
for text in ("altyazi mk", "ALTYAZI M.K.", "Altyazı M.K.!"):
|
||||||
|
with self.subTest(text=text):
|
||||||
|
self.assertTrue(vad.looks_like_hallucination(text, 2.0))
|
||||||
|
|
||||||
|
def test_the_boundary_is_the_max_duration(self):
|
||||||
|
self.assertTrue(vad.looks_like_hallucination("you", 6.0))
|
||||||
|
self.assertFalse(vad.looks_like_hallucination("you", 6.1))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
"""The dictation chain end to end, with every outside call faked.
|
||||||
|
|
||||||
|
This is the one test that says what a dictation actually does: what gets sent,
|
||||||
|
what gets pasted, what is written to the history, and what happens to the audio
|
||||||
|
afterwards. A pull request that reorders any of it shows up here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import api
|
||||||
|
import assistant
|
||||||
|
import config as cfg
|
||||||
|
import paste
|
||||||
|
import worker
|
||||||
|
from tests.support import DikteTest, make_wav, speech
|
||||||
|
|
||||||
|
|
||||||
|
class Chain(DikteTest):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.conf = self.config(openai_api_key="sk-test",
|
||||||
|
openrouter_api_key="sk-or-test")
|
||||||
|
self.wav = make_wav(self.path("clip.wav"), speech(2.0))
|
||||||
|
# The levels a real recording of that length would have handed over.
|
||||||
|
self.rms = [0.0005] * 40 + [0.2] * 20
|
||||||
|
|
||||||
|
def run_chain(self, ask=False, paste_override=None, duration=2.0,
|
||||||
|
transcript="uh, book it for Thursday",
|
||||||
|
cleaned="Book it for Thursday.",
|
||||||
|
cleanup_error=None, answer=("Booked.", ""), rms=None,
|
||||||
|
clipboard=b"what was there before"):
|
||||||
|
pipeline = worker.Pipeline(self.conf)
|
||||||
|
done, failures, stages, cancels = [], [], [], []
|
||||||
|
pipeline.finished.connect(lambda *args: done.append(args))
|
||||||
|
pipeline.failed.connect(failures.append)
|
||||||
|
pipeline.stage.connect(stages.append)
|
||||||
|
pipeline.cancelled.connect(lambda: cancels.append(True))
|
||||||
|
|
||||||
|
cleanup = (mock.Mock(side_effect=cleanup_error) if cleanup_error
|
||||||
|
else mock.Mock(return_value=cleaned))
|
||||||
|
calls = {}
|
||||||
|
# The chain reports its own failures on stderr, which a test run has no
|
||||||
|
# use for.
|
||||||
|
with contextlib.redirect_stderr(io.StringIO()), \
|
||||||
|
mock.patch.object(api, "transcribe", return_value=transcript) as tr, \
|
||||||
|
mock.patch.object(api, "cleanup", cleanup), \
|
||||||
|
mock.patch.object(assistant, "ask", return_value=answer) as ask_call, \
|
||||||
|
mock.patch.object(paste, "copy") as copy, \
|
||||||
|
mock.patch.object(paste, "copy_bytes") as copy_bytes, \
|
||||||
|
mock.patch.object(paste, "press") as press, \
|
||||||
|
mock.patch.object(paste, "read_clipboard", return_value=clipboard), \
|
||||||
|
mock.patch.object(worker.time, "sleep", lambda seconds: None):
|
||||||
|
calls = {"transcribe": tr, "cleanup": cleanup, "ask": ask_call,
|
||||||
|
"copy": copy, "copy_bytes": copy_bytes, "press": press}
|
||||||
|
pipeline._work(self.wav, duration,
|
||||||
|
self.rms if rms is None else rms, ask, paste_override)
|
||||||
|
return {"done": done, "failures": failures, "stages": stages,
|
||||||
|
"cancelled": cancels, **calls}
|
||||||
|
|
||||||
|
# ---- the ordinary run -------------------------------------------------
|
||||||
|
|
||||||
|
def test_a_dictation_is_transcribed_cleaned_copied_and_pasted(self):
|
||||||
|
run = self.run_chain()
|
||||||
|
self.assertEqual(run["failures"], [])
|
||||||
|
self.assertEqual(run["done"][0],
|
||||||
|
("uh, book it for Thursday", "Book it for Thursday.", ""))
|
||||||
|
run["copy"].assert_called_once_with("Book it for Thursday.")
|
||||||
|
run["press"].assert_called_once_with(self.conf["paste_shortcut"])
|
||||||
|
|
||||||
|
def test_the_stages_are_named_as_they_happen(self):
|
||||||
|
run = self.run_chain()
|
||||||
|
self.assertEqual(run["stages"][:2], ["Transcribing…", "Cleaning up…"])
|
||||||
|
|
||||||
|
def test_cleanup_switched_off_pastes_what_was_heard(self):
|
||||||
|
self.conf["cleanup_enabled"] = False
|
||||||
|
run = self.run_chain()
|
||||||
|
run["cleanup"].assert_not_called()
|
||||||
|
run["copy"].assert_called_once_with("uh, book it for Thursday")
|
||||||
|
|
||||||
|
def test_auto_paste_switched_off_only_copies(self):
|
||||||
|
self.conf["auto_paste"] = False
|
||||||
|
run = self.run_chain()
|
||||||
|
run["copy"].assert_called_once()
|
||||||
|
run["press"].assert_not_called()
|
||||||
|
|
||||||
|
def test_a_run_asked_for_from_a_terminal_pastes_nowhere(self):
|
||||||
|
"""The text comes back down the socket; the focused window is nobody's."""
|
||||||
|
run = self.run_chain(paste_override=False)
|
||||||
|
run["press"].assert_not_called()
|
||||||
|
run["copy"].assert_called_once()
|
||||||
|
|
||||||
|
def test_the_clipboard_is_put_back_afterwards(self):
|
||||||
|
self.conf["restore_clipboard"] = True
|
||||||
|
run = self.run_chain()
|
||||||
|
run["copy_bytes"].assert_called_once_with(b"what was there before")
|
||||||
|
|
||||||
|
def test_nothing_is_put_back_when_the_setting_is_off(self):
|
||||||
|
self.conf["restore_clipboard"] = False
|
||||||
|
run = self.run_chain()
|
||||||
|
run["copy_bytes"].assert_not_called()
|
||||||
|
|
||||||
|
def test_the_transcription_is_told_the_language_and_the_glossary(self):
|
||||||
|
self.conf["language"] = "tr"
|
||||||
|
self.conf["transcribe_prompt"] = "Paraşüt"
|
||||||
|
run = self.run_chain()
|
||||||
|
self.assertEqual(run["transcribe"].call_args.kwargs["language"], "tr")
|
||||||
|
self.assertEqual(run["transcribe"].call_args.kwargs["prompt"], "Paraşüt")
|
||||||
|
|
||||||
|
# ---- silence and stock phrases ----------------------------------------
|
||||||
|
|
||||||
|
def test_room_tone_costs_no_api_call(self):
|
||||||
|
run = self.run_chain(rms=[0.00001] * 60)
|
||||||
|
run["transcribe"].assert_not_called()
|
||||||
|
self.assertIn("No speech", run["failures"][0])
|
||||||
|
|
||||||
|
def test_the_silence_check_can_be_switched_off(self):
|
||||||
|
self.conf["skip_silent"] = False
|
||||||
|
run = self.run_chain(rms=[0.00001] * 60)
|
||||||
|
run["transcribe"].assert_called_once()
|
||||||
|
|
||||||
|
def test_a_stock_phrase_from_a_short_clip_is_thrown_away(self):
|
||||||
|
run = self.run_chain(duration=2.0, transcript="Altyazı M.K.")
|
||||||
|
self.assertIn("stock phrase", run["failures"][0])
|
||||||
|
run["copy"].assert_not_called()
|
||||||
|
|
||||||
|
def test_the_hallucination_filter_can_be_switched_off(self):
|
||||||
|
self.conf["filter_hallucinations"] = False
|
||||||
|
run = self.run_chain(duration=2.0, transcript="Altyazı M.K.")
|
||||||
|
run["copy"].assert_called_once()
|
||||||
|
|
||||||
|
# ---- when something goes wrong ----------------------------------------
|
||||||
|
|
||||||
|
def test_a_failed_cleanup_still_pastes_the_transcript(self):
|
||||||
|
run = self.run_chain(cleanup_error=api.ApiError("rate limited"))
|
||||||
|
_raw, text, warning = run["done"][0]
|
||||||
|
self.assertEqual(text, "uh, book it for Thursday")
|
||||||
|
self.assertIn("rate limited", warning)
|
||||||
|
run["copy"].assert_called_once_with("uh, book it for Thursday")
|
||||||
|
|
||||||
|
def test_a_failed_cleanup_is_never_silent(self):
|
||||||
|
"""A rejected key would otherwise look like dictation that works."""
|
||||||
|
run = self.run_chain(cleanup_error=api.ApiError("bad key"))
|
||||||
|
self.assertTrue(run["done"][0][2])
|
||||||
|
self.assertEqual(cfg.read_history()[0]["cleanup_error"], "bad key")
|
||||||
|
|
||||||
|
def test_a_failed_transcription_ends_the_run(self):
|
||||||
|
pipeline = worker.Pipeline(self.conf)
|
||||||
|
failures = []
|
||||||
|
pipeline.failed.connect(failures.append)
|
||||||
|
with mock.patch.object(api, "transcribe",
|
||||||
|
side_effect=api.ApiError("no credit")), \
|
||||||
|
mock.patch.object(paste, "copy") as copy:
|
||||||
|
pipeline._work(self.wav, 2.0, self.rms, False, None)
|
||||||
|
self.assertIn("no credit", failures[0])
|
||||||
|
copy.assert_not_called()
|
||||||
|
|
||||||
|
def test_a_clipboard_that_will_not_take_it(self):
|
||||||
|
pipeline = worker.Pipeline(self.conf)
|
||||||
|
failures = []
|
||||||
|
pipeline.failed.connect(failures.append)
|
||||||
|
with mock.patch.object(api, "transcribe", return_value="hello"), \
|
||||||
|
mock.patch.object(api, "cleanup", return_value="Hello."), \
|
||||||
|
mock.patch.object(paste, "read_clipboard", return_value=None), \
|
||||||
|
mock.patch.object(paste, "copy",
|
||||||
|
side_effect=paste.PasteError("no wl-copy")):
|
||||||
|
pipeline._work(self.wav, 2.0, self.rms, False, None)
|
||||||
|
self.assertIn("wl-copy", failures[0])
|
||||||
|
|
||||||
|
def test_an_unexpected_error_is_reported_rather_than_swallowed(self):
|
||||||
|
pipeline = worker.Pipeline(self.conf)
|
||||||
|
failures = []
|
||||||
|
pipeline.failed.connect(failures.append)
|
||||||
|
with mock.patch.object(api, "transcribe", side_effect=ValueError("oh dear")), \
|
||||||
|
mock.patch("traceback.print_exc"):
|
||||||
|
pipeline._work(self.wav, 2.0, self.rms, False, None)
|
||||||
|
self.assertIn("oh dear", failures[0])
|
||||||
|
|
||||||
|
# ---- handing it to an agent -------------------------------------------
|
||||||
|
|
||||||
|
def test_a_command_goes_to_the_agent_and_the_answer_comes_back(self):
|
||||||
|
run = self.run_chain(ask=True)
|
||||||
|
run["ask"].assert_called_once()
|
||||||
|
self.assertEqual(run["ask"].call_args.args[0], "uh, book it for Thursday")
|
||||||
|
run["copy"].assert_called_once_with("Booked.")
|
||||||
|
|
||||||
|
def test_a_command_is_not_cleaned_up_first_by_default(self):
|
||||||
|
"""The agent reads through the filler words without help."""
|
||||||
|
run = self.run_chain(ask=True)
|
||||||
|
run["cleanup"].assert_not_called()
|
||||||
|
|
||||||
|
def test_a_command_can_be_cleaned_up_if_you_want(self):
|
||||||
|
self.conf["assistant_cleanup"] = True
|
||||||
|
run = self.run_chain(ask=True)
|
||||||
|
run["cleanup"].assert_called_once()
|
||||||
|
self.assertEqual(run["ask"].call_args.args[0], "Book it for Thursday.")
|
||||||
|
|
||||||
|
def test_a_denied_tool_arrives_beside_the_answer(self):
|
||||||
|
run = self.run_chain(ask=True, answer=("Booked.", "It could not use: Bash"))
|
||||||
|
self.assertIn("Bash", run["done"][0][2])
|
||||||
|
|
||||||
|
def test_the_agent_has_its_own_paste_setting(self):
|
||||||
|
self.conf["assistant_paste"] = False
|
||||||
|
run = self.run_chain(ask=True)
|
||||||
|
run["press"].assert_not_called()
|
||||||
|
|
||||||
|
def test_a_command_that_was_cancelled(self):
|
||||||
|
pipeline = worker.Pipeline(self.conf)
|
||||||
|
cancels = []
|
||||||
|
pipeline.cancelled.connect(lambda: cancels.append(True))
|
||||||
|
with mock.patch.object(api, "transcribe", return_value="hello"), \
|
||||||
|
mock.patch.object(assistant, "ask", side_effect=assistant.Cancelled):
|
||||||
|
pipeline._work(self.wav, 2.0, self.rms, True, None)
|
||||||
|
self.assertEqual(cancels, [True])
|
||||||
|
|
||||||
|
def test_an_agent_that_is_not_installed(self):
|
||||||
|
pipeline = worker.Pipeline(self.conf)
|
||||||
|
failures = []
|
||||||
|
pipeline.failed.connect(failures.append)
|
||||||
|
with mock.patch.object(api, "transcribe", return_value="hello"), \
|
||||||
|
mock.patch.object(assistant, "ask",
|
||||||
|
side_effect=assistant.AssistantError("no claude")):
|
||||||
|
pipeline._work(self.wav, 2.0, self.rms, True, None)
|
||||||
|
self.assertIn("no claude", failures[0])
|
||||||
|
|
||||||
|
# ---- what is left behind ----------------------------------------------
|
||||||
|
|
||||||
|
def test_the_run_is_written_to_the_history(self):
|
||||||
|
self.run_chain()
|
||||||
|
row = cfg.read_history()[0]
|
||||||
|
self.assertEqual(row["raw"], "uh, book it for Thursday")
|
||||||
|
self.assertEqual(row["text"], "Book it for Thursday.")
|
||||||
|
self.assertEqual(row["duration"], 2.0)
|
||||||
|
self.assertEqual(row["model"], self.conf["transcribe_model"])
|
||||||
|
self.assertEqual(row["mode"], "")
|
||||||
|
|
||||||
|
def test_a_command_is_recorded_as_one(self):
|
||||||
|
self.run_chain(ask=True)
|
||||||
|
row = cfg.read_history()[0]
|
||||||
|
self.assertEqual(row["mode"], "ask")
|
||||||
|
self.assertEqual(row["question"], "uh, book it for Thursday")
|
||||||
|
self.assertEqual(row["text"], "Booked.")
|
||||||
|
|
||||||
|
def test_the_history_is_kept_to_its_limit(self):
|
||||||
|
self.conf["history_limit"] = 2
|
||||||
|
for _ in range(4):
|
||||||
|
self.wav = make_wav(self.path("clip.wav"), speech(2.0))
|
||||||
|
self.run_chain()
|
||||||
|
self.assertEqual(len(cfg.read_history()), 2)
|
||||||
|
|
||||||
|
def test_the_recording_is_deleted_when_it_is_done_with(self):
|
||||||
|
self.run_chain()
|
||||||
|
self.assertFalse(os.path.exists(self.wav))
|
||||||
|
|
||||||
|
def test_the_recording_is_kept_when_the_setting_says_so(self):
|
||||||
|
self.conf["keep_audio"] = True
|
||||||
|
self.run_chain()
|
||||||
|
self.assertFalse(os.path.exists(self.wav))
|
||||||
|
self.assertEqual(len(list(cfg.RECORDINGS_DIR.iterdir())), 1)
|
||||||
|
|
||||||
|
def test_the_recording_goes_even_when_the_run_failed(self):
|
||||||
|
self.run_chain(rms=[0.00001] * 60)
|
||||||
|
self.assertFalse(os.path.exists(self.wav))
|
||||||
|
|
||||||
|
|
||||||
|
class Busy(DikteTest):
|
||||||
|
def test_a_second_run_while_one_is_going_is_ignored(self):
|
||||||
|
pipeline = worker.Pipeline(self.config())
|
||||||
|
pipeline._thread = mock.Mock(is_alive=lambda: True)
|
||||||
|
self.assertTrue(pipeline.busy)
|
||||||
|
with mock.patch.object(worker.threading, "Thread") as thread:
|
||||||
|
pipeline.run("/tmp/nope.wav", 1.0)
|
||||||
|
thread.assert_not_called()
|
||||||
|
|
||||||
|
def test_the_chunk_length_matches_the_level_meter(self):
|
||||||
|
"""The silence thresholds are read in seconds, so the two must agree."""
|
||||||
|
self.assertAlmostEqual(worker.CHUNK_SECONDS, 1024 / 16000)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user