mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 19:06:11 +00:00
Merge pull request #7 from yusufipk/test-suite
Test what the application does, before the pull requests land
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.
|
||||||
@@ -361,9 +361,11 @@ def _find_meeting(which):
|
|||||||
return None
|
return None
|
||||||
if which in ("", "last"):
|
if which in ("", "last"):
|
||||||
return rows[-1]
|
return rows[-1]
|
||||||
if which.isdigit():
|
# A stem is all digits too, so a number is only a position while there are
|
||||||
index = int(which)
|
# that many meetings to count back through. Anything larger is a date
|
||||||
return rows[-index] if 0 < index <= len(rows) else None
|
# somebody typed: nobody is looking for the twenty-millionth meeting.
|
||||||
|
if which.isdigit() and 0 < int(which) <= len(rows):
|
||||||
|
return rows[-int(which)]
|
||||||
exact = [row for row in rows if row["base"] == which]
|
exact = [row for row in rows if row["base"] == which]
|
||||||
if exact:
|
if exact:
|
||||||
return exact[0]
|
return exact[0]
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ def language():
|
|||||||
return _lang
|
return _lang
|
||||||
|
|
||||||
|
|
||||||
def t(text, **kwargs):
|
def t(text, /, **kwargs):
|
||||||
|
# The string is positional-only so that every name is free to be a
|
||||||
|
# placeholder: t("Discarded: {text}", text=…) would otherwise be two values
|
||||||
|
# for one argument, and fail at the moment the message is shown.
|
||||||
out = TR.get(text, text) if _lang == "tr" else text
|
out = TR.get(text, text) if _lang == "tr" else text
|
||||||
return out.format(**kwargs) if kwargs else out
|
return out.format(**kwargs) if kwargs else out
|
||||||
|
|
||||||
@@ -42,7 +45,7 @@ _TR_CASES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def name(text, case=""):
|
def name(text, /, case=""):
|
||||||
if _lang != "tr" or not case:
|
if _lang != "tr" or not case:
|
||||||
return text
|
return text
|
||||||
return _TR_CASES.get(case, {}).get(text, text)
|
return _TR_CASES.get(case, {}).get(text, text)
|
||||||
|
|||||||
@@ -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