diff --git a/AGENTS.md b/AGENTS.md index dd001c7..f537727 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,9 +33,9 @@ This repository is intentionally kept minimal: - `src/clipboardwriter.*`: clipboard integration, preferring KDE system clipboard support - `src/audio/recordingnormalizer.*`: conversion to Whisper-ready mono `float32` at `16 kHz` - `src/transcription/whispercpptranscriber.*`: in-process Whisper integration -- `src/transcription/transcriptionengine.*`: app-owned engine/session seam for backend selection and future runtime evolution +- `src/transcription/transcriptionengine.*`: app-owned engine/session seam for backend selection and future runtime evolution; the engine owns immutable runtime metadata such as backend capabilities - `src/transcription/transcriptionworker.*`: worker object hosted on a dedicated `QThread` -- `src/transcription/transcriptiontypes.h`: normalized audio and transcription result value types +- `src/transcription/transcriptiontypes.h`: normalized audio, typed runtime error, and capability value types - `src/config.*`: JSON config loading and defaults - `src/app/*`: shared CLI/runtime command helpers used by the main entrypoint - `src/control/*`: local daemon control transport, typed snapshots, and session/client APIs @@ -120,6 +120,7 @@ Notes: - Use `bash scripts/check-release-hygiene.sh` when touching publication-facing files such as `README.md`, licenses, `contrib/`, CI, or helper scripts - Use `cmake --build "$BUILD_DIR" --target docs` when touching repo-owned public headers, Doxygen config, the Doxygen main page, or CI/docs wiring - If install rules or licensing files change, confirm the temporary install contains the expected files under `share/licenses/mutterkey` +- If you add or change public methods in repo-owned headers, expect `cmake --build "$BUILD_DIR" --target docs` to fail until the new API is documented; treat that as part of the normal implementation loop, not follow-up polish ## Tooling Best Practices @@ -136,6 +137,7 @@ Notes: - Reconfigure the build directory after installing new tools so cached `find_program()` results are refreshed - When validating inside a restricted sandbox, be ready to disable `ccache` with `CCACHE_DISABLE=1` if the cache location is read-only; that is an execution-environment issue, not a Mutterkey build failure - Prefer fixing the code over weakening `.clang-tidy` or the Clazy check set; only relax tool config when the warning is clearly low-value for this repo +- If `clang-tidy` flags a new small enum for `performance-enum-size`, prefer an explicit narrow underlying type such as `std::uint8_t` instead of suppressing the warning - In this Qt-heavy repo, treat `misc-include-cleaner` and `readability-redundant-access-specifiers` as low-value `clang-tidy` noise unless the underlying tool behavior improves; they conflict with Qt header-provider reality and `signals` / `slots` / `Q_SLOTS` sectioning more than they improve safety - Prefer anonymous-namespace `Q_LOGGING_CATEGORY` for file-local logging categories; `Q_STATIC_LOGGING_CATEGORY` is not portable enough across the Qt versions this repo may build against - Do not add broad Valgrind suppressions by default; only add narrow suppressions after reproducing stable third-party noise and keep them clearly scoped @@ -156,8 +158,9 @@ Notes: - Prefer narrow shared value types across subsystems; for example, consumers that only need captured audio should include `src/audio/recording.h`, not the full recorder class - Keep JSON and other transport details at subsystem boundaries; prefer typed C++ snapshots/results once data crosses into app-owned control, tray, or service code - Prefer dependency injection for tray-shell and control-surface code from the first implementation so headless Qt tests stay simple -- When preparing the transcription path for future runtime work, prefer app-owned engine/session seams and injected sessions over leaking concrete backend types into CLI, service, or worker orchestration +- When preparing the transcription path for future runtime work, prefer app-owned engine/session seams and injected sessions over leaking concrete backend types into CLI, service, or worker orchestration. Keep immutable capability reporting and backend metadata on the engine side, and keep the session side focused on mutable decode state, warmup, and transcription - Prefer product-owned runtime interfaces, model/session separation, and deterministic backend selection before adding new inference backends or widening cross-platform support +- Keep backend-specific validation out of `src/config.*` when practical. Product config parsing should normalize and preserve user input, while backend support checks should live in the app-owned runtime layer near `src/transcription/*` - Preserve the current product direction: embedded `whisper.cpp`, KDE-first, CLI/service-first ## C++ Core Guidelines Priorities @@ -233,7 +236,7 @@ Typical model location: - Treat `mutterkey-tray` as a shipped artifact once it is installed or validated in CI; keep install rules, README/setup notes, release checklist items, and workflow checks aligned with that status - Verify with a fresh CMake build when the change affects compilation or linkage - Run `ctest` when touching covered code in `src/config.*` or `src/audio/recordingnormalizer.*`, and extend the deterministic headless tests when practical -- When touching transcription orchestration or backend seams, prefer small headless tests with fake/injected sessions over model-dependent integration tests +- When touching transcription orchestration or backend seams, prefer small headless tests with fake/injected sessions or fake engines over model-dependent integration tests. Engine injection is the preferred seam for orchestration tests; direct session injection is still useful for narrow worker behavior - When adding or fixing Qt GUI tests, make the `CTest` registration itself headless with `QT_QPA_PLATFORM=offscreen` so CI does not try to load `xcb` - Prefer expanding tests around pure parsing, value normalization, and other environment-independent logic before adding KDE-session or device-heavy coverage - Use `-DMUTTERKEY_ENABLE_ASAN=ON` and `-DMUTTERKEY_ENABLE_UBSAN=ON` for fast iteration on memory and UB bugs, and use the repo-owned Valgrind lane as the slower release-focused confirmation step @@ -243,7 +246,7 @@ Typical model location: - Prefer the `lint` target for a full pre-handoff analyzer pass, and use the individual analyzer targets when iterating on one class of warnings - Run `bash scripts/run-valgrind.sh "$BUILD_DIR"` before handoff when the task is specifically about memory, ownership, lifetime, shutdown, or release hardening - Run `bash scripts/check-release-hygiene.sh` before handoff when the task touches publication-facing files or repository metadata -- If `QT_QPA_PLATFORM=offscreen "$BUILD_DIR/mutterkey" diagnose 1` fails in a headless environment without useful output, note that limitation explicitly rather than assuming a docs-only or packaging-only change regressed runtime behavior +- If `QT_QPA_PLATFORM=offscreen "$BUILD_DIR/mutterkey" diagnose 1` fails in a headless environment after model loading or during KDE/session-dependent startup, note that limitation explicitly rather than assuming the runtime seam or docs-only change regressed behavior - Do not leave generated artifacts in the repository tree at the end of the task - Do not assume every workspace copy is an initialized git repository; if `git` commands fail, continue with file-based validation and mention the limitation in the final response diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 6d226f1..7c6d20e 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -26,6 +26,22 @@ bash scripts/check-release-hygiene.sh ## Build And Test +- For the automated pre-install portion of this section, you can run: + +```bash +bash scripts/run-release-checklist.sh +``` + +- Pass extra CMake configure arguments after `--` when you want to exercise an + accelerated release build. For example: + +```bash +bash scripts/run-release-checklist.sh -- -DMUTTERKEY_ENABLE_WHISPER_CUDA=ON +``` + +- The script intentionally stops before install validation and still prints the + remaining manual review items that need human judgment. + - Configure a fresh out-of-tree build: ```bash diff --git a/docs/mainpage.md b/docs/mainpage.md index dba58b1..3ef0e7a 100644 --- a/docs/mainpage.md +++ b/docs/mainpage.md @@ -6,8 +6,8 @@ `KDE Plasma`. This documentation is generated from the repo-owned C++ headers under `src/`. -It focuses on the application's core interfaces, ownership boundaries, and the -main daemon-mode workflow. +It focuses on the application's ownership boundaries, runtime contracts, and +daemon-oriented workflow rather than end-user setup. Current behavior: @@ -17,12 +17,17 @@ Current behavior: - copies the resulting text to the clipboard - expects you to paste the text yourself with `Ctrl+V` -Current direction: +Current runtime shape: -- KDE-first -- local-only transcription -- CLI/service-first operation -- tray-shell work has started, but the daemon remains the product core +- `TranscriptionEngine` is the immutable runtime/provider boundary +- `TranscriptionSession` is the mutable per-session decode boundary +- `BackendCapabilities` reports engine-owned runtime metadata used for + diagnostics and orchestration +- `RuntimeError` and `RuntimeErrorCode` provide typed runtime failures +- `TranscriptionWorker` hosts transcription on a dedicated `QThread` and + creates live sessions lazily on that worker thread +- config parsing under `src/config.*` stays product-shaped and permissive, while + backend-specific support checks live in the runtime layer Core API surface covered here: @@ -30,10 +35,21 @@ Core API surface covered here: - `AudioRecorder` captures microphone audio while the shortcut is held. - `RecordingNormalizer` converts captured audio to Whisper-ready mono `float32` samples at `16 kHz`. +- `TranscriptionEngine` and `TranscriptionSession` define the app-owned runtime + seam. - `WhisperCppTranscriber` performs in-process transcription through vendored `whisper.cpp`. - `ClipboardWriter` copies the resulting text to the clipboard. - `MutterkeyService` coordinates those pieces on the main thread plus a dedicated transcription worker thread. -For build, runtime, and service setup use the repository `README.md`. +Current product direction: + +- KDE-first +- local-only transcription +- CLI/service-first operation +- tray-shell work exists, but the daemon remains the product core +- `whisper.cpp` is still the only supported backend implementation + +For build, runtime, release, and service setup use the repository `README.md` +and `RELEASE_CHECKLIST.md`. diff --git a/scripts/run-release-checklist.sh b/scripts/run-release-checklist.sh new file mode 100644 index 0000000..ddc8a18 --- /dev/null +++ b/scripts/run-release-checklist.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: bash scripts/run-release-checklist.sh [--build-dir DIR] [--skip-diagnose] [-- ] + +Runs the automated portion of RELEASE_CHECKLIST.md up to, but not including, +install validation. + +Options: + --build-dir DIR Use an existing or chosen build directory instead of a new tmpdir. + --skip-diagnose Skip the headless `mutterkey diagnose 1` step. + --help Show this help text. + +Examples: + bash scripts/run-release-checklist.sh + bash scripts/run-release-checklist.sh --build-dir /tmp/mutterkey-build-abc123 + bash scripts/run-release-checklist.sh -- -DMUTTERKEY_ENABLE_WHISPER_VULKAN=ON +EOF +} + +die() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +note() { + printf '==> %s\n' "$*" +} + +run_cmd() { + note "$*" + "$@" +} + +run_build_target() { + shift + + local output + if output="$("$@" 2>&1)"; then + printf '%s\n' "$output" + return 0 + fi + + printf '%s\n' "$output" >&2 + if grep -Fq 'ccache: error: Read-only file system' <<<"$output"; then + note "Retrying with CCACHE_DISABLE=1 because ccache is read-only in this environment" + CCACHE_DISABLE=1 "$@" + return 0 + fi + + die "Build command failed" +} + +assert_file_exists() { + local path="$1" + [[ -e "$path" ]] || die "Required file is missing: $path" +} + +contains_vendored_rule() { + grep -Fqx 'third_party/whisper.cpp/** linguist-vendored' .gitattributes +} + +scan_for_model_binaries() { + if command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git ls-files | grep -E '(^|/)(ggml-.*\.bin|.*\.(bin|gguf))$' | grep -Ev '^third_party/whisper\.cpp/' || true + return + fi + + find . \ + -path './.git' -prune -o \ + -path './third_party/whisper.cpp' -prune -o \ + -path './build' -prune -o \ + -path './build-*' -prune -o \ + -path './cmake-build-*' -prune -o \ + \( -name '*.bin' -o -name '*.gguf' \) -print +} + +build_dir="" +skip_diagnose=0 +declare -a extra_cmake_args=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --build-dir) + [[ $# -ge 2 ]] || die "--build-dir requires a value" + build_dir="$2" + shift 2 + ;; + --skip-diagnose) + skip_diagnose=1 + shift + ;; + --help) + usage + exit 0 + ;; + --) + shift + extra_cmake_args=("$@") + break + ;; + *) + die "Unknown argument: $1" + ;; + esac +done + +if [[ -z "$build_dir" ]]; then + build_dir="$(mktemp -d /tmp/mutterkey-build-XXXXXX)" +fi + +declare -a generator_args=() +if command -v ninja >/dev/null 2>&1 || command -v ninja-build >/dev/null 2>&1; then + generator_args=(-G Ninja) +fi + +note "Release checklist build directory: $build_dir" + +assert_file_exists LICENSE +assert_file_exists THIRD_PARTY_NOTICES.md +assert_file_exists third_party/whisper.cpp.UPSTREAM.md +assert_file_exists .gitattributes + +contains_vendored_rule || die "Missing vendored linguist rule for third_party/whisper.cpp in .gitattributes" + +tracked_binaries="$(scan_for_model_binaries)" +if [[ -n "$tracked_binaries" ]]; then + die "Unexpected model/binary artifacts found:\n$tracked_binaries" +fi + +run_cmd bash scripts/check-release-hygiene.sh + +run_cmd cmake -S . -B "$build_dir" "${generator_args[@]}" -DCMAKE_BUILD_TYPE=Debug -DGGML_CCACHE=OFF "${extra_cmake_args[@]}" +run_build_target "$build_dir" cmake --build "$build_dir" -j"$(nproc)" +run_cmd ctest --test-dir "$build_dir" --output-on-failure +run_cmd bash scripts/run-valgrind.sh "$build_dir" +if command -v clang-tidy >/dev/null 2>&1; then + run_build_target "$build_dir" cmake --build "$build_dir" --target clang-tidy +else + note "Skipping clang-tidy because clang-tidy is not installed" +fi + +if command -v clazy-standalone >/dev/null 2>&1; then + run_build_target "$build_dir" cmake --build "$build_dir" --target clazy +else + note "Skipping clazy because clazy-standalone is not installed" +fi + +if command -v doxygen >/dev/null 2>&1; then + run_build_target "$build_dir" cmake --build "$build_dir" --target docs +else + note "Skipping docs because doxygen is not installed" +fi +run_cmd env QT_QPA_PLATFORM=offscreen "$build_dir/mutterkey" --help + +note "Running tray-shell smoke check" +set +e +timeout 2s env QT_QPA_PLATFORM=offscreen "$build_dir/mutterkey-tray" +tray_status=$? +set -e +if [[ $tray_status -ne 0 && $tray_status -ne 124 ]]; then + die "Headless tray-shell smoke check failed with exit code $tray_status" +fi + +if [[ $skip_diagnose -eq 0 ]]; then + run_cmd env QT_QPA_PLATFORM=offscreen "$build_dir/mutterkey" diagnose 1 +else + note "Skipping headless diagnose step by request" +fi + +cat < transcriptionEngine = createTranscriptionEngine(config.transcriber); + MutterkeyService service(config, transcriptionEngine, QGuiApplication::clipboard()); DaemonControlServer controlServer(configPath, config, &service); QObject::connect(&app, &QCoreApplication::aboutToQuit, &service, &MutterkeyService::stop); QObject::connect(&app, &QCoreApplication::aboutToQuit, &controlServer, &DaemonControlServer::stop); @@ -59,14 +60,14 @@ int runDaemon(QGuiApplication &app, const AppConfig &config, const QString &conf int runOnce(QGuiApplication &app, const AppConfig &config, double seconds) { AudioRecorder recorder(config.audio); - const std::unique_ptr transcriptionEngine = createTranscriptionEngine(config.transcriber); + const std::shared_ptr transcriptionEngine = createTranscriptionEngine(config.transcriber); std::unique_ptr transcriber = transcriptionEngine->createSession(); ClipboardWriter clipboardWriter(QGuiApplication::clipboard()); if (config.transcriber.warmupOnStart) { - QString warmupError; - if (!transcriber->warmup(&warmupError)) { - qCCritical(appLog) << "Failed to warm up transcriber:" << warmupError; + RuntimeError runtimeError; + if (!transcriber->warmup(&runtimeError)) { + qCCritical(appLog) << "Failed to warm up transcriber:" << runtimeError.message; return 1; } } @@ -90,7 +91,7 @@ int runOnce(QGuiApplication &app, const AppConfig &config, double seconds) const TranscriptionResult result = transcriber->transcribe(recording); if (!result.success) { - qCCritical(appLog) << "One-shot transcription failed:" << result.error; + qCCritical(appLog) << "One-shot transcription failed:" << result.error.message; QGuiApplication::exit(1); return; } @@ -113,7 +114,8 @@ int runOnce(QGuiApplication &app, const AppConfig &config, double seconds) int runDiagnose(QGuiApplication &app, const AppConfig &config, double seconds, bool invokeShortcut) { - MutterkeyService service(config, QGuiApplication::clipboard()); + const std::shared_ptr transcriptionEngine = createTranscriptionEngine(config.transcriber); + MutterkeyService service(config, transcriptionEngine, QGuiApplication::clipboard()); QObject::connect(&app, &QCoreApplication::aboutToQuit, &service, &MutterkeyService::stop); QString errorMessage; diff --git a/src/config.cpp b/src/config.cpp index b19daa9..648b136 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -18,10 +18,6 @@ #include #include -extern "C" { -#include -} - namespace { Q_LOGGING_CATEGORY(configLog, "mutterkey.config") @@ -82,39 +78,6 @@ QString normalizedLanguageValue(const QString &value) return value.trimmed().toLower(); } -bool resolveWhisperLanguage(const QString &value, QString *resolvedLanguage) -{ - const QString normalizedValue = normalizedLanguageValue(value); - if (normalizedValue.isEmpty()) { - return false; - } - - if (normalizedValue == QStringLiteral("auto")) { - if (resolvedLanguage != nullptr) { - *resolvedLanguage = normalizedValue; - } - return true; - } - - for (int languageId = 0; languageId <= whisper_lang_max_id(); ++languageId) { - const char *languageCode = whisper_lang_str(languageId); - const char *languageName = whisper_lang_str_full(languageId); - if (languageCode == nullptr) { - continue; - } - - if (normalizedValue == QString::fromUtf8(languageCode) - || (languageName != nullptr && normalizedValue == QString::fromUtf8(languageName))) { - if (resolvedLanguage != nullptr) { - *resolvedLanguage = QString::fromUtf8(languageCode); - } - return true; - } - } - - return false; -} - bool parseBoolValue(const QString &value, bool *parsedValue) { if (parsedValue == nullptr) { @@ -290,15 +253,15 @@ bool setModelPath(AppConfig *config, const QString &value, QString *errorMessage bool setLanguage(AppConfig *config, const QString &value, QString *errorMessage) { - QString resolvedLanguage; - if (!resolveWhisperLanguage(value, &resolvedLanguage)) { + const QString normalizedLanguage = normalizedLanguageValue(value); + if (normalizedLanguage.isEmpty()) { if (errorMessage != nullptr) { - *errorMessage = QStringLiteral("transcriber.language must be \"auto\" or a supported Whisper language code"); + *errorMessage = QStringLiteral("transcriber.language may not be empty"); } return false; } - config->transcriber.language = resolvedLanguage; + config->transcriber.language = normalizedLanguage; return true; } @@ -436,15 +399,15 @@ AppConfig loadConfigObject(const QJsonObject &root, const QString &sourceName) config.transcriber.modelPath = modelPath; } config.transcriber.language = readString(transcriber, QStringLiteral("language"), config.transcriber.language); - QString resolvedLanguage; - if (resolveWhisperLanguage(config.transcriber.language, &resolvedLanguage)) { - config.transcriber.language = resolvedLanguage; - } else { + const QString normalizedLanguage = normalizedLanguageValue(config.transcriber.language); + if (normalizedLanguage.isEmpty()) { warnAboutInvalidValue(sourceName, QStringLiteral("transcriber.language"), - QStringLiteral("unsupported Whisper language"), + QStringLiteral("value is empty"), defaultAppConfig().transcriber.language); config.transcriber.language = defaultAppConfig().transcriber.language; + } else { + config.transcriber.language = normalizedLanguage; } config.transcriber.translate = readBool(transcriber, QStringLiteral("translate"), config.transcriber.translate); config.transcriber.threads = validatedThreads(sourceName, readInt(transcriber, QStringLiteral("threads"), config.transcriber.threads)); diff --git a/src/control/daemoncontrolserver.cpp b/src/control/daemoncontrolserver.cpp index a3c4c98..f1d8f6b 100644 --- a/src/control/daemoncontrolserver.cpp +++ b/src/control/daemoncontrolserver.cpp @@ -11,10 +11,21 @@ DaemonControlServer::DaemonControlServer(QString configPath, AppConfig config, const MutterkeyService *service, QObject *parent) + : DaemonControlServer( + std::move(configPath), std::move(config), service, daemonControlSocketName(), parent) +{ +} + +DaemonControlServer::DaemonControlServer(QString configPath, + AppConfig config, + const MutterkeyService *service, + QString socketName, + QObject *parent) : QObject(parent) , m_configPath(std::move(configPath)) , m_config(std::move(config)) , m_service(service) + , m_socketName(std::move(socketName)) , m_server(new QLocalServer(this)) { connect(m_server, &QLocalServer::newConnection, this, &DaemonControlServer::onNewConnection); @@ -27,8 +38,8 @@ DaemonControlServer::~DaemonControlServer() bool DaemonControlServer::start(QString *errorMessage) { - QLocalServer::removeServer(daemonControlSocketName()); - if (m_server->listen(daemonControlSocketName())) { + QLocalServer::removeServer(m_socketName); + if (m_server->listen(m_socketName)) { return true; } @@ -42,7 +53,7 @@ void DaemonControlServer::stop() { if (m_server->isListening()) { m_server->close(); - QLocalServer::removeServer(daemonControlSocketName()); + QLocalServer::removeServer(m_socketName); } } @@ -87,7 +98,7 @@ QByteArray DaemonControlServer::handleRequest(const QByteArray &payload) const switch (request.method) { case DaemonControlMethod::Ping: response.result.insert(QStringLiteral("application"), QCoreApplication::applicationName()); - response.result.insert(QStringLiteral("socket_name"), daemonControlSocketName()); + response.result.insert(QStringLiteral("socket_name"), m_socketName); break; case DaemonControlMethod::GetStatus: response.result = daemonStatusSnapshotToJsonObject(buildStatusSnapshot()); diff --git a/src/control/daemoncontrolserver.h b/src/control/daemoncontrolserver.h index 3992024..0ab9b4a 100644 --- a/src/control/daemoncontrolserver.h +++ b/src/control/daemoncontrolserver.h @@ -1,5 +1,6 @@ #pragma once +#include "control/daemoncontrolprotocol.h" #include "control/daemoncontroltypes.h" #include "config.h" #include "service.h" @@ -32,6 +33,19 @@ class DaemonControlServer final : public QObject AppConfig config, const MutterkeyService *service, QObject *parent = nullptr); + /** + * @brief Creates the local control server for a running daemon on a custom socket name. + * @param configPath Resolved daemon config path. + * @param config Startup config snapshot exposed through the control API. + * @param service Non-owning pointer to the running daemon service. + * @param socketName Injected local socket name for deterministic tests or alternate local control endpoints. + * @param parent Optional QObject parent. + */ + explicit DaemonControlServer(QString configPath, + AppConfig config, + const MutterkeyService *service, + QString socketName, + QObject *parent); /** * @brief Stops the local server and releases the socket name. */ @@ -63,5 +77,7 @@ private Q_SLOTS: QString m_configPath; AppConfig m_config; const MutterkeyService *m_service = nullptr; + /// Socket name used by both the listener and ping diagnostics payloads. + QString m_socketName; QLocalServer *m_server = nullptr; }; diff --git a/src/service.cpp b/src/service.cpp index 1e83841..6c9bf93 100644 --- a/src/service.cpp +++ b/src/service.cpp @@ -2,6 +2,8 @@ #include #include +#include +#include namespace { @@ -9,13 +11,19 @@ Q_LOGGING_CATEGORY(serviceLog, "mutterkey.service") } // namespace -MutterkeyService::MutterkeyService(const AppConfig &config, QClipboard *clipboard, QObject *parent) +MutterkeyService::MutterkeyService(const AppConfig &config, + std::shared_ptr transcriptionEngine, + QClipboard *clipboard, + QObject *parent) : QObject(parent) , m_config(config) , m_audioRecorder(config.audio, this) + , m_transcriptionEngine(std::move(transcriptionEngine)) , m_clipboardWriter(clipboard, this) , m_hotkeyManager(config.shortcut, this) { + Q_ASSERT(m_transcriptionEngine != nullptr); + qRegisterMetaType("RuntimeError"); connect(&m_hotkeyManager, &HotkeyManager::shortcutPressed, this, &MutterkeyService::onShortcutPressed); connect(&m_hotkeyManager, &HotkeyManager::shortcutReleased, this, &MutterkeyService::onShortcutReleased); } @@ -72,6 +80,11 @@ QJsonObject MutterkeyService::diagnostics() const object.insert(QStringLiteral("transcriptions_completed"), m_transcriptionsCompleted); object.insert(QStringLiteral("transcriber_backend"), m_transcriptionWorker != nullptr ? m_transcriptionWorker->backendName() : QStringLiteral("unconfigured")); + const BackendCapabilities capabilities = + m_transcriptionWorker != nullptr ? m_transcriptionWorker->capabilities() : m_transcriptionEngine->capabilities(); + object.insert(QStringLiteral("transcriber_runtime"), capabilities.runtimeDescription); + object.insert(QStringLiteral("transcriber_supports_translation"), capabilities.supportsTranslation); + object.insert(QStringLiteral("transcriber_supports_auto_language"), capabilities.supportsAutoLanguage); return object; } @@ -133,15 +146,18 @@ void MutterkeyService::onTranscriptionReady(const QString &text) } } -void MutterkeyService::onTranscriptionFailed(const QString &errorMessage) +void MutterkeyService::onTranscriptionFailed(const RuntimeError &error) { - qCWarning(serviceLog) << "Transcription failed:" << errorMessage; + qCWarning(serviceLog) << "Transcription failed:" << error.message; } void MutterkeyService::transcribeInBackground(Recording recording) { if (m_transcriptionWorker == nullptr) { - emit transcriptionFailed(QStringLiteral("Transcription worker is not running")); + emit transcriptionFailed(RuntimeError{ + .code = RuntimeErrorCode::InternalRuntimeError, + .message = QStringLiteral("Transcription worker is not running"), + }); return; } @@ -163,7 +179,7 @@ bool MutterkeyService::startTranscriptionWorker(QString *errorMessage) // The worker owns the transcriber backend but lives on a dedicated thread so hotkey // and recorder callbacks do not block on model initialization or inference. - m_transcriptionWorker = new TranscriptionWorker(m_config.transcriber); + m_transcriptionWorker = new TranscriptionWorker(m_transcriptionEngine); m_transcriptionWorker->moveToThread(&m_transcriptionThread); connect(&m_transcriptionThread, &QThread::finished, m_transcriptionWorker, &QObject::deleteLater); @@ -174,7 +190,7 @@ bool MutterkeyService::startTranscriptionWorker(QString *errorMessage) if (m_config.transcriber.warmupOnStart) { bool warmupOk = false; - QString warmupError; + RuntimeError warmupError; // Warmup has to run on the worker thread because that thread also owns the // transcriber object for the remainder of the process lifetime. QMetaObject::invokeMethod(m_transcriptionWorker, @@ -184,7 +200,7 @@ bool MutterkeyService::startTranscriptionWorker(QString *errorMessage) Qt::BlockingQueuedConnection); if (!warmupOk) { if (errorMessage != nullptr) { - *errorMessage = warmupError; + *errorMessage = warmupError.message; } stopTranscriptionWorker(); return false; diff --git a/src/service.h b/src/service.h index b42b886..7014eab 100644 --- a/src/service.h +++ b/src/service.h @@ -33,10 +33,14 @@ class MutterkeyService final : public QObject /** * @brief Creates the service with a fixed runtime configuration. * @param config Startup configuration snapshot copied into the service. + * @param transcriptionEngine Shared immutable engine used by the worker thread. * @param clipboard Non-owning pointer to the application clipboard. * @param parent Optional QObject parent. */ - explicit MutterkeyService(const AppConfig &config, QClipboard *clipboard, QObject *parent = nullptr); + explicit MutterkeyService(const AppConfig &config, + std::shared_ptr transcriptionEngine, + QClipboard *clipboard, + QObject *parent = nullptr); /** * @brief Stops background work and joins the transcription thread. @@ -79,9 +83,9 @@ class MutterkeyService final : public QObject /** * @brief Emitted when recording or transcription fails. - * @param errorMessage Human-readable failure description. + * @param error Structured failure description. */ - void transcriptionFailed(const QString &errorMessage); + void transcriptionFailed(const RuntimeError &error); private Q_SLOTS: /** @@ -102,9 +106,9 @@ private Q_SLOTS: /** * @brief Handles transcription failures reported by the worker thread. - * @param errorMessage Human-readable failure description. + * @param error Structured failure description. */ - void onTranscriptionFailed(const QString &errorMessage); + void onTranscriptionFailed(const RuntimeError &error); private: /** @@ -129,6 +133,8 @@ private Q_SLOTS: AppConfig m_config; /// Main-thread recorder used while the push-to-talk shortcut is held. AudioRecorder m_audioRecorder; + /// Shared immutable runtime provider used to construct worker-thread sessions. + std::shared_ptr m_transcriptionEngine; /// Clipboard delivery helper for successful transcription results. ClipboardWriter m_clipboardWriter; /// KDE global shortcut registration and diagnostics helper. diff --git a/src/transcription/transcriptionengine.cpp b/src/transcription/transcriptionengine.cpp index 0adb8bb..a3e827b 100644 --- a/src/transcription/transcriptionengine.cpp +++ b/src/transcription/transcriptionengine.cpp @@ -2,7 +2,6 @@ #include "transcription/whispercpptranscriber.h" -#include #include namespace { @@ -15,9 +14,9 @@ class WhisperCppTranscriptionEngine final : public TranscriptionEngine { } - [[nodiscard]] QString backendName() const override + [[nodiscard]] BackendCapabilities capabilities() const override { - return WhisperCppTranscriber::backendNameStatic(); + return WhisperCppTranscriber::capabilitiesStatic(); } [[nodiscard]] std::unique_ptr createSession() const override @@ -31,7 +30,7 @@ class WhisperCppTranscriptionEngine final : public TranscriptionEngine } // namespace -std::unique_ptr createTranscriptionEngine(const TranscriberConfig &config) +std::shared_ptr createTranscriptionEngine(const TranscriberConfig &config) { - return std::make_unique(config); + return std::make_shared(config); } diff --git a/src/transcription/transcriptionengine.h b/src/transcription/transcriptionengine.h index ef280c2..6aaec56 100644 --- a/src/transcription/transcriptionengine.h +++ b/src/transcription/transcriptionengine.h @@ -35,10 +35,10 @@ class TranscriptionSession /** * @brief Performs optional backend warmup for this session. - * @param errorMessage Optional destination for a human-readable failure reason. + * @param error Optional destination for a structured failure reason. * @return `true` if the session is ready for transcription, otherwise `false`. */ - virtual bool warmup(QString *errorMessage = nullptr) = 0; + virtual bool warmup(RuntimeError *error = nullptr) = 0; /** * @brief Transcribes a single captured recording. @@ -67,10 +67,10 @@ class TranscriptionEngine TranscriptionEngine &operator=(TranscriptionEngine &&) = delete; /** - * @brief Returns the backend identifier for sessions created by this engine. - * @return Short backend name used for logs and diagnostics. + * @brief Returns the runtime capabilities for sessions created by this engine. + * @return App-owned capability snapshot suitable for diagnostics. */ - [[nodiscard]] virtual QString backendName() const = 0; + [[nodiscard]] virtual BackendCapabilities capabilities() const = 0; /** * @brief Creates a new isolated transcription session. @@ -87,4 +87,4 @@ class TranscriptionEngine * @param config Backend configuration copied into the engine. * @return Engine suitable for creating isolated transcription sessions. */ -[[nodiscard]] std::unique_ptr createTranscriptionEngine(const TranscriberConfig &config); +[[nodiscard]] std::shared_ptr createTranscriptionEngine(const TranscriberConfig &config); diff --git a/src/transcription/transcriptiontypes.h b/src/transcription/transcriptiontypes.h index bf1bd84..54fd1b9 100644 --- a/src/transcription/transcriptiontypes.h +++ b/src/transcription/transcriptiontypes.h @@ -1,7 +1,9 @@ #pragma once #include +#include +#include #include /** @@ -9,6 +11,56 @@ * @brief Shared value types exchanged by the transcription pipeline. */ +/** + * @brief Stable categories for runtime-layer failures. + */ +enum class RuntimeErrorCode : std::uint8_t { + None, + InvalidConfig, + ModelNotFound, + ModelLoadFailed, + AudioNormalizationFailed, + UnsupportedLanguage, + DecodeFailed, + InternalRuntimeError, +}; + +/** + * @brief Structured runtime-layer failure with user-facing and diagnostic text. + */ +struct RuntimeError { + /// Stable error category for programmatic handling and tests. + RuntimeErrorCode code = RuntimeErrorCode::None; + /// Human-readable summary safe to surface in logs or UI. + QString message; + /// Optional extra context for diagnostics. + QString detail; + + /** + * @brief Reports whether this value represents success. + * @return `true` when no runtime error is present. + */ + [[nodiscard]] bool isOk() const { return code == RuntimeErrorCode::None; } +}; + +/** + * @brief Product-owned backend/runtime metadata surfaced to app code. + */ +struct BackendCapabilities { + /// Stable backend identifier used in diagnostics. + QString backendName; + /// Human-readable runtime and device summary for diagnostics. + QString runtimeDescription; + /// Supported language codes accepted by this backend. + QStringList supportedLanguages; + /// `true` when the backend can auto-detect the spoken language. + bool supportsAutoLanguage = false; + /// `true` when the backend supports translation mode. + bool supportsTranslation = false; + /// `true` when warmup is a supported preflight operation. + bool supportsWarmup = false; +}; + /** * @brief Result of a single transcription attempt. */ @@ -17,8 +69,8 @@ struct TranscriptionResult { bool success = false; /// Final recognized text when `success` is `true`. QString text; - /// Human-readable failure reason when `success` is `false`. - QString error; + /// Structured runtime failure when `success` is `false`. + RuntimeError error; }; /** @@ -38,3 +90,7 @@ struct NormalizedAudio { */ [[nodiscard]] bool isValid() const { return !samples.empty(); } }; + +Q_DECLARE_METATYPE(RuntimeErrorCode) +Q_DECLARE_METATYPE(RuntimeError) +Q_DECLARE_METATYPE(BackendCapabilities) diff --git a/src/transcription/transcriptionworker.cpp b/src/transcription/transcriptionworker.cpp index f3f72a4..6c4410f 100644 --- a/src/transcription/transcriptionworker.cpp +++ b/src/transcription/transcriptionworker.cpp @@ -3,9 +3,21 @@ #include #include -TranscriptionWorker::TranscriptionWorker(const TranscriberConfig &config, QObject *parent) - : TranscriptionWorker(createTranscriptionEngine(config)->createSession(), parent) +namespace { + +RuntimeError makeRuntimeError(RuntimeErrorCode code, QString message) +{ + return RuntimeError{.code = code, .message = std::move(message)}; +} + +} // namespace + +TranscriptionWorker::TranscriptionWorker(std::shared_ptr engine, QObject *parent) + : QObject(parent) + , m_engine(std::move(engine)) { + assert(m_engine != nullptr); + m_capabilities = m_engine->capabilities(); } TranscriptionWorker::TranscriptionWorker(std::unique_ptr transcriber, QObject *parent) @@ -13,20 +25,36 @@ TranscriptionWorker::TranscriptionWorker(std::unique_ptr t , m_transcriber(std::move(transcriber)) { assert(m_transcriber != nullptr); + m_capabilities.backendName = m_transcriber->backendName(); } QString TranscriptionWorker::backendName() const { - return m_transcriber->backendName(); + return capabilities().backendName; } -bool TranscriptionWorker::warmup(QString *errorMessage) +BackendCapabilities TranscriptionWorker::capabilities() const { - return m_transcriber->warmup(errorMessage); + return m_capabilities; +} + +bool TranscriptionWorker::warmup(RuntimeError *error) +{ + if (!ensureSession(error)) { + return false; + } + + return m_transcriber->warmup(error); } void TranscriptionWorker::transcribe(const Recording &recording) { + RuntimeError runtimeError; + if (!ensureSession(&runtimeError)) { + emit transcriptionFailed(runtimeError); + return; + } + const TranscriptionResult result = m_transcriber->transcribe(recording); if (!result.success) { emit transcriptionFailed(result.error); @@ -35,3 +63,28 @@ void TranscriptionWorker::transcribe(const Recording &recording) emit transcriptionReady(result.text); } + +bool TranscriptionWorker::ensureSession(RuntimeError *error) +{ + if (m_transcriber != nullptr) { + return true; + } + + if (m_engine == nullptr) { + if (error != nullptr) { + *error = makeRuntimeError(RuntimeErrorCode::InternalRuntimeError, + QStringLiteral("Transcription engine is not configured")); + } + return false; + } + + m_transcriber = m_engine->createSession(); + if (m_transcriber == nullptr) { + if (error != nullptr) { + *error = makeRuntimeError(RuntimeErrorCode::InternalRuntimeError, + QStringLiteral("Failed to create a transcription session")); + } + return false; + } + return true; +} diff --git a/src/transcription/transcriptionworker.h b/src/transcription/transcriptionworker.h index 0cce128..609f36d 100644 --- a/src/transcription/transcriptionworker.h +++ b/src/transcription/transcriptionworker.h @@ -1,7 +1,6 @@ #pragma once #include "audio/recording.h" -#include "config.h" #include "transcription/transcriptionengine.h" #include @@ -25,11 +24,11 @@ class TranscriptionWorker final : public QObject public: /** - * @brief Creates a worker with a fixed backend configuration. - * @param config Transcriber settings copied into the owned backend. + * @brief Creates a worker with a shared immutable engine. + * @param engine Shared engine used to lazily create the live session. * @param parent Optional QObject parent. */ - explicit TranscriptionWorker(const TranscriberConfig &config, QObject *parent = nullptr); + explicit TranscriptionWorker(std::shared_ptr engine, QObject *parent = nullptr); /** * @brief Creates a worker around an already-constructed session. * @param transcriber Owned session implementation. @@ -46,12 +45,18 @@ class TranscriptionWorker final : public QObject */ [[nodiscard]] QString backendName() const; + /** + * @brief Returns the active runtime capability snapshot. + * @return Capability data for diagnostics and orchestration decisions. + */ + [[nodiscard]] BackendCapabilities capabilities() const; + /** * @brief Eagerly initializes backend state before the first real transcription. - * @param errorMessage Optional output for warmup failures. + * @param error Optional output for warmup failures. * @return `true` when the backend is ready for use. */ - bool warmup(QString *errorMessage = nullptr); + bool warmup(RuntimeError *error = nullptr); /** * @brief Transcribes a captured recording and emits a result signal. @@ -68,11 +73,17 @@ class TranscriptionWorker final : public QObject /** * @brief Emitted when transcription fails. - * @param errorMessage Human-readable failure description. + * @param error Structured failure description. */ - void transcriptionFailed(const QString &errorMessage); + void transcriptionFailed(const RuntimeError &error); private: + bool ensureSession(RuntimeError *error = nullptr); + + /// Shared immutable engine used to create the live session lazily on the worker thread. + std::shared_ptr m_engine; + /// Capability snapshot reported even before the first session exists. + BackendCapabilities m_capabilities; /// Owned transcription backend implementation. std::unique_ptr m_transcriber; }; diff --git a/src/transcription/whispercpptranscriber.cpp b/src/transcription/whispercpptranscriber.cpp index e376fe8..31bbb64 100644 --- a/src/transcription/whispercpptranscriber.cpp +++ b/src/transcription/whispercpptranscriber.cpp @@ -59,6 +59,35 @@ QString describeRegisteredBackends() .arg(backendNames.join(QStringLiteral(", ")), deviceDescriptions.join(QStringLiteral(" | "))); } +RuntimeError makeRuntimeError(RuntimeErrorCode code, QString message, QString detail = {}) +{ + return RuntimeError{.code = code, .message = std::move(message), .detail = std::move(detail)}; +} + +RuntimeError makeUnsupportedLanguageError(const QString &language) +{ + return makeRuntimeError(RuntimeErrorCode::UnsupportedLanguage, + QStringLiteral("Embedded Whisper does not support language: %1").arg(language), + QStringLiteral("Requested language code: %1").arg(language)); +} + +bool isSupportedLanguageCode(const QString &language) +{ + const QString normalizedLanguage = language.trimmed().toLower(); + if (normalizedLanguage.isEmpty() || normalizedLanguage == QStringLiteral("auto")) { + return true; + } + + for (int languageId = 0; languageId <= whisper_lang_max_id(); ++languageId) { + const char *languageCode = whisper_lang_str(languageId); + if (languageCode != nullptr && normalizedLanguage == QString::fromUtf8(languageCode)) { + return true; + } + } + + return false; +} + } // namespace WhisperCppTranscriber::WhisperCppTranscriber(TranscriberConfig config) @@ -81,26 +110,54 @@ QString WhisperCppTranscriber::backendNameStatic() return QStringLiteral("whisper.cpp"); } +BackendCapabilities WhisperCppTranscriber::capabilitiesStatic() +{ + BackendCapabilities capabilities; + capabilities.backendName = backendNameStatic(); + capabilities.runtimeDescription = describeRegisteredBackends(); + capabilities.supportsAutoLanguage = true; + capabilities.supportsTranslation = true; + capabilities.supportsWarmup = true; + capabilities.supportedLanguages.reserve(whisper_lang_max_id() + 1); + for (int languageId = 0; languageId <= whisper_lang_max_id(); ++languageId) { + const char *languageCode = whisper_lang_str(languageId); + if (languageCode != nullptr) { + capabilities.supportedLanguages.append(QString::fromUtf8(languageCode)); + } + } + return capabilities; +} + QString WhisperCppTranscriber::backendName() const { return backendNameStatic(); } -bool WhisperCppTranscriber::warmup(QString *errorMessage) +bool WhisperCppTranscriber::warmup(RuntimeError *error) { - return ensureInitialized(errorMessage); + return ensureInitialized(error); } TranscriptionResult WhisperCppTranscriber::transcribe(const Recording &recording) { - QString errorMessage; - if (!ensureInitialized(&errorMessage)) { - return TranscriptionResult{.success = false, .text = {}, .error = errorMessage}; + const QString requestedLanguage = m_config.language.trimmed().toLower(); + if (!isSupportedLanguageCode(requestedLanguage)) { + return TranscriptionResult{.success = false, .text = {}, .error = makeUnsupportedLanguageError(requestedLanguage)}; } + RuntimeError runtimeError; + if (!ensureInitialized(&runtimeError)) { + return TranscriptionResult{.success = false, .text = {}, .error = runtimeError}; + } + + QString errorMessage; NormalizedAudio normalizedAudio; if (!m_normalizer.normalizeForWhisper(recording, &normalizedAudio, &errorMessage)) { - return TranscriptionResult{.success = false, .text = {}, .error = errorMessage}; + return TranscriptionResult{ + .success = false, + .text = {}, + .error = makeRuntimeError(RuntimeErrorCode::AudioNormalizationFailed, errorMessage), + }; } qCInfo(whisperCppLog) << "Normalized recording to" @@ -134,7 +191,8 @@ TranscriptionResult WhisperCppTranscriber::transcribe(const Recording &recording return TranscriptionResult{ .success = false, .text = {}, - .error = QStringLiteral("Embedded Whisper transcription failed with code %1").arg(result), + .error = makeRuntimeError(RuntimeErrorCode::DecodeFailed, + QStringLiteral("Embedded Whisper transcription failed with code %1").arg(result)), }; } @@ -159,7 +217,7 @@ TranscriptionResult WhisperCppTranscriber::transcribe(const Recording &recording return TranscriptionResult{.success = true, .text = transcript.trimmed(), .error = {}}; } -bool WhisperCppTranscriber::ensureInitialized(QString *errorMessage) +bool WhisperCppTranscriber::ensureInitialized(RuntimeError *error) { if (m_context != nullptr) { return true; @@ -169,8 +227,10 @@ bool WhisperCppTranscriber::ensureInitialized(QString *errorMessage) // the exact model file whisper.cpp is attempting to load. const QString modelPath = QFileInfo(m_config.modelPath).absoluteFilePath(); if (modelPath.isEmpty() || !QFileInfo::exists(modelPath)) { - if (errorMessage != nullptr) { - *errorMessage = QStringLiteral("Embedded Whisper model not found: %1").arg(m_config.modelPath); + if (error != nullptr) { + *error = makeRuntimeError(RuntimeErrorCode::ModelNotFound, + QStringLiteral("Embedded Whisper model not found: %1").arg(m_config.modelPath), + modelPath); } return false; } @@ -179,8 +239,9 @@ bool WhisperCppTranscriber::ensureInitialized(QString *errorMessage) qCInfo(whisperCppLog).noquote() << "ggml runtime:" << describeRegisteredBackends(); m_context.reset(whisper_init_from_file_with_params(modelPath.toUtf8().constData(), contextParams)); if (m_context == nullptr) { - if (errorMessage != nullptr) { - *errorMessage = QStringLiteral("Failed to load embedded Whisper model: %1").arg(modelPath); + if (error != nullptr) { + *error = makeRuntimeError(RuntimeErrorCode::ModelLoadFailed, + QStringLiteral("Failed to load embedded Whisper model: %1").arg(modelPath)); } return false; } diff --git a/src/transcription/whispercpptranscriber.h b/src/transcription/whispercpptranscriber.h index 079bdcf..015d747 100644 --- a/src/transcription/whispercpptranscriber.h +++ b/src/transcription/whispercpptranscriber.h @@ -45,14 +45,20 @@ class WhisperCppTranscriber final : public TranscriptionSession * @return Human-readable backend identifier. */ [[nodiscard]] static QString backendNameStatic(); + + /** + * @brief Returns the static capability snapshot for the whisper.cpp runtime. + * @return Capability data derived from the embedded backend integration. + */ + [[nodiscard]] static BackendCapabilities capabilitiesStatic(); [[nodiscard]] QString backendName() const override; /** * @brief Eagerly initializes the whisper.cpp context. - * @param errorMessage Optional output for initialization failures. + * @param error Optional output for initialization failures. * @return `true` when the backend is ready for transcription. */ - bool warmup(QString *errorMessage = nullptr) override; + bool warmup(RuntimeError *error = nullptr) override; /** * @brief Normalizes and transcribes one captured recording. @@ -70,10 +76,10 @@ class WhisperCppTranscriber final : public TranscriptionSession /** * @brief Initializes the backend on first use. - * @param errorMessage Optional output for initialization failures. + * @param error Optional output for initialization failures. * @return `true` when the backend context is available. */ - bool ensureInitialized(QString *errorMessage = nullptr); + bool ensureInitialized(RuntimeError *error = nullptr); /// Immutable whisper.cpp runtime configuration. TranscriberConfig m_config; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a96f789..cd5f988 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -35,6 +35,10 @@ mutterkey_add_qt_test(daemoncontroltypestest ../src/control/daemoncontroltypes.cpp ) +mutterkey_add_qt_test(daemoncontrolclientservertest + daemoncontrolclientservertest.cpp +) + mutterkey_add_qt_test(transcriptionworkertest transcriptionworkertest.cpp ../src/audio/recordingnormalizer.cpp @@ -55,13 +59,14 @@ mutterkey_add_qt_test(traystatuswindowtest target_link_libraries(configtest PRIVATE whisper) target_link_libraries(commanddispatchtest PRIVATE whisper) target_link_libraries(daemoncontroltypestest PRIVATE whisper) +target_link_libraries(daemoncontrolclientservertest PRIVATE mutterkey_control) target_link_libraries(transcriptionworkertest PRIVATE whisper) target_link_libraries(traystatuswindowtest PRIVATE whisper) if(TARGET clang-tidy) - add_dependencies(clang-tidy configtest_autogen commanddispatchtest_autogen recordingnormalizertest_autogen daemoncontrolprotocoltest_autogen daemoncontroltypestest_autogen transcriptionworkertest_autogen traystatuswindowtest_autogen) + add_dependencies(clang-tidy configtest_autogen commanddispatchtest_autogen recordingnormalizertest_autogen daemoncontrolprotocoltest_autogen daemoncontroltypestest_autogen daemoncontrolclientservertest_autogen transcriptionworkertest_autogen traystatuswindowtest_autogen) endif() if(TARGET clazy) - add_dependencies(clazy configtest_autogen commanddispatchtest_autogen recordingnormalizertest_autogen daemoncontrolprotocoltest_autogen daemoncontroltypestest_autogen transcriptionworkertest_autogen traystatuswindowtest_autogen) + add_dependencies(clazy configtest_autogen commanddispatchtest_autogen recordingnormalizertest_autogen daemoncontrolprotocoltest_autogen daemoncontroltypestest_autogen daemoncontrolclientservertest_autogen transcriptionworkertest_autogen traystatuswindowtest_autogen) endif() diff --git a/tests/commanddispatchtest.cpp b/tests/commanddispatchtest.cpp index 42e145b..4b04247 100644 --- a/tests/commanddispatchtest.cpp +++ b/tests/commanddispatchtest.cpp @@ -22,6 +22,11 @@ private slots: void CommandDispatchTest::commandIndexSkipsGlobalOptionValues() { + // WHAT: Verify that command discovery finds the real subcommand position. + // HOW: Pass arguments that include global options and their values before the command, + // then check that the returned index points at "config" instead of one of the option values. + // WHY: Command routing depends on this index being correct, and a wrong index would make + // valid CLI input behave like an unknown or malformed command. const QStringList arguments{ QStringLiteral("mutterkey"), QStringLiteral("--config"), @@ -36,6 +41,11 @@ void CommandDispatchTest::commandIndexSkipsGlobalOptionValues() void CommandDispatchTest::bareConfigShowsDedicatedHelp() { + // WHAT: Verify that the bare "config" command opens config-specific help. + // HOW: Build an argument list with only the top-level "config" command and assert that + // the helper decides to show the dedicated config help text. + // WHY: Users often start by typing a command name on its own, so this keeps the first + // help experience clear instead of falling through to generic CLI behavior. const QStringList arguments{ QStringLiteral("mutterkey"), QStringLiteral("config"), @@ -47,6 +57,11 @@ void CommandDispatchTest::bareConfigShowsDedicatedHelp() void CommandDispatchTest::configHelpFlagShowsDedicatedHelp() { + // WHAT: Verify that "--help" after the "config" command still selects config help. + // HOW: Include a global option before "config", add "--help" after it, and confirm + // the help-selection logic chooses the config-specific help path. + // WHY: This protects a common support path where users ask the CLI for targeted help + // while still using global overrides such as a custom config file. const QStringList arguments{ QStringLiteral("mutterkey"), QStringLiteral("--config"), @@ -61,6 +76,11 @@ void CommandDispatchTest::configHelpFlagShowsDedicatedHelp() void CommandDispatchTest::nonConfigCommandsDoNotShowConfigHelp() { + // WHAT: Verify that non-config commands do not accidentally trigger config help. + // HOW: Use a normal "diagnose" invocation and assert that the helper reports that + // config help should not be shown. + // WHY: Help detection must stay narrow, otherwise unrelated commands would become + // confusing or unusable because they would be redirected to the wrong documentation. const QStringList arguments{ QStringLiteral("mutterkey"), QStringLiteral("diagnose"), @@ -73,6 +93,11 @@ void CommandDispatchTest::nonConfigCommandsDoNotShowConfigHelp() void CommandDispatchTest::configHelpTextMentionsSubcommands() { + // WHAT: Verify that the config help text mentions the main config workflows. + // HOW: Read the generated help text and check that it includes the expected command + // shapes and examples such as "init", "set", and key option hints. + // WHY: This test protects the CLI's self-documentation, which is especially important + // when users rely on the help text instead of external documentation. const QString helpText = configHelpText(); QVERIFY(helpText.contains(QStringLiteral("config "))); @@ -84,6 +109,11 @@ void CommandDispatchTest::configHelpTextMentionsSubcommands() void CommandDispatchTest::configHelpTextListsAllSupportedConfigKeys() { + // WHAT: Verify that every supported config key is listed in the help text. + // HOW: Iterate through the canonical supported-key list and assert that each key + // appears in the rendered help output. + // WHY: The help text should stay aligned with the implementation so users can discover + // every editable setting without guessing or reading the source code. const QString helpText = configHelpText(); for (const QString &key : supportedConfigKeys()) { diff --git a/tests/configtest.cpp b/tests/configtest.cpp index 8ffcbf1..3a29cbb 100644 --- a/tests/configtest.cpp +++ b/tests/configtest.cpp @@ -19,16 +19,24 @@ private slots: void loadConfigIgnoresWrongJsonTypes(); void loadConfigTrimsImportantStringFields(); void saveConfigRoundTripsResolvedValues(); + void configJsonObjectRoundTripsResolvedValues(); void saveConfigCreatesParentDirectory(); void applyConfigValueUpdatesSupportedFields(); void applyConfigValueRejectsInvalidInputs(); void applyConfigValueRejectsUnknownKeys(); + void applyConfigValueAcceptsBooleanFalseSpellings_data(); + void applyConfigValueAcceptsBooleanFalseSpellings(); }; } // namespace void ConfigTest::defaultAppConfigMatchesDocumentedDefaults() { + // WHAT: Verify that the built-in default configuration matches the documented defaults. + // HOW: Create the default config in memory and compare each important field against the + // expected values described by the product's default behavior. + // WHY: These defaults define how Mutterkey behaves before a user customizes anything, + // so silent drift here would make documentation and first-run behavior disagree. const AppConfig config = defaultAppConfig(); QCOMPARE(config.shortcut.sequence, QStringLiteral("F8")); @@ -45,6 +53,11 @@ void ConfigTest::defaultAppConfigMatchesDocumentedDefaults() void ConfigTest::loadConfigUsesDefaultsWhenFileIsMissing() { + // WHAT: Verify that loading a missing config file falls back to safe defaults. + // HOW: Ask the loader to read a path that does not exist and confirm that it returns + // the default values without reporting a hard error. + // WHY: A missing file is a normal first-run situation, and the app must stay usable + // instead of failing just because configuration has not been created yet. const QTemporaryDir tempDir; QVERIFY(tempDir.isValid()); @@ -66,6 +79,11 @@ void ConfigTest::loadConfigUsesDefaultsWhenFileIsMissing() void ConfigTest::loadConfigAppliesJsonOverrides() { + // WHAT: Verify that valid JSON config values override the built-in defaults. + // HOW: Write a complete config file with custom values, load it, and compare the + // resulting in-memory config against those custom values. + // WHY: User configuration is only meaningful if saved values reliably take effect, + // so this test protects the main customization path. const QTemporaryDir tempDir; QVERIFY(tempDir.isValid()); @@ -120,6 +138,11 @@ void ConfigTest::loadConfigAppliesJsonOverrides() void ConfigTest::loadConfigRejectsInvalidValues() { + // WHAT: Verify that invalid config values are rejected and replaced with defaults. + // HOW: Load a config file that contains empty or out-of-range values, + // then confirm that the resolved config falls back to the safe default values. + // WHY: Configuration files can be edited by hand or become corrupted, and the app must + // recover predictably instead of carrying invalid runtime settings forward. const QTemporaryDir tempDir; QVERIFY(tempDir.isValid()); @@ -132,12 +155,12 @@ void ConfigTest::loadConfigRejectsInvalidValues() }, "audio": { "sample_rate": 0, - "channels": 0, + "channels": 9, "minimum_seconds": -1.0 }, "transcriber": { "model_path": " ", - "language": "pirate", + "language": " ", "threads": -4 }, "log_level": "verbose" @@ -158,8 +181,48 @@ void ConfigTest::loadConfigRejectsInvalidValues() QCOMPARE(config.logLevel, QStringLiteral("INFO")); } +void ConfigTest::configJsonObjectRoundTripsResolvedValues() +{ + // WHAT: Verify that the in-memory config JSON conversion round-trips resolved values. + // HOW: Build a config with custom values, convert it to a JSON object, load that object + // back through the shared parser, and compare the resulting config fields. + // WHY: The daemon control plane uses in-memory config JSON objects rather than files, so + // this test protects that contract directly instead of only the file-based path. + AppConfig config = defaultAppConfig(); + config.shortcut.sequence = QStringLiteral("Meta+F8"); + config.audio.sampleRate = 48000; + config.audio.channels = 2; + config.audio.minimumSeconds = 0.0; + config.audio.deviceId = QStringLiteral("usb-mic"); + config.transcriber.modelPath = QStringLiteral("/tmp/test-model.bin"); + config.transcriber.language = QStringLiteral("fi"); + config.transcriber.translate = true; + config.transcriber.threads = 6; + config.transcriber.warmupOnStart = true; + config.logLevel = QStringLiteral("DEBUG"); + + const AppConfig loadedConfig = loadConfigObject(configToJsonObject(config), QStringLiteral("test round trip")); + + QCOMPARE(loadedConfig.shortcut.sequence, config.shortcut.sequence); + QCOMPARE(loadedConfig.audio.sampleRate, config.audio.sampleRate); + QCOMPARE(loadedConfig.audio.channels, config.audio.channels); + QCOMPARE(loadedConfig.audio.minimumSeconds, config.audio.minimumSeconds); + QCOMPARE(loadedConfig.audio.deviceId, config.audio.deviceId); + QCOMPARE(loadedConfig.transcriber.modelPath, config.transcriber.modelPath); + QCOMPARE(loadedConfig.transcriber.language, config.transcriber.language); + QCOMPARE(loadedConfig.transcriber.translate, config.transcriber.translate); + QCOMPARE(loadedConfig.transcriber.threads, config.transcriber.threads); + QCOMPARE(loadedConfig.transcriber.warmupOnStart, config.transcriber.warmupOnStart); + QCOMPARE(loadedConfig.logLevel, config.logLevel); +} + void ConfigTest::loadConfigReportsMalformedJson() { + // WHAT: Verify that malformed JSON is reported as an error. + // HOW: Save a broken JSON document, load it, and assert that an explanatory error + // message is returned while the resolved config falls back to defaults. + // WHY: When the file itself is syntactically broken, users need a clear signal that + // the problem is the file format and not an unrelated runtime failure. const QTemporaryDir tempDir; QVERIFY(tempDir.isValid()); @@ -184,6 +247,11 @@ void ConfigTest::loadConfigReportsMalformedJson() void ConfigTest::loadConfigIgnoresWrongJsonTypes() { + // WHAT: Verify that values with the wrong JSON type are ignored. + // HOW: Provide numbers where strings are expected, strings where booleans are expected, + // and similar mismatches, then confirm that defaults remain in place. + // WHY: This keeps config loading robust when users or tools produce structurally valid + // JSON that still does not match the schema Mutterkey expects. const QTemporaryDir tempDir; QVERIFY(tempDir.isValid()); @@ -228,6 +296,11 @@ void ConfigTest::loadConfigIgnoresWrongJsonTypes() void ConfigTest::loadConfigTrimsImportantStringFields() { + // WHAT: Verify that important string settings are trimmed and normalized on load. + // HOW: Load values padded with extra whitespace and mixed casing, then check that the + // resolved config stores the cleaned, normalized forms. + // WHY: Small formatting mistakes in a hand-edited config should not break the app or + // force users to debug invisible whitespace problems. const QTemporaryDir tempDir; QVERIFY(tempDir.isValid()); @@ -258,6 +331,11 @@ void ConfigTest::loadConfigTrimsImportantStringFields() void ConfigTest::saveConfigRoundTripsResolvedValues() { + // WHAT: Verify that saving and then reloading a config preserves the resolved values. + // HOW: Build a config with custom values, save it to disk, load it back, and compare + // the loaded fields with the original in-memory values. + // WHY: This protects the persistence path so that a saved configuration comes back the + // same way on the next run instead of silently changing user settings. const QTemporaryDir tempDir; QVERIFY(tempDir.isValid()); @@ -296,6 +374,11 @@ void ConfigTest::saveConfigRoundTripsResolvedValues() void ConfigTest::saveConfigCreatesParentDirectory() { + // WHAT: Verify that saving a config creates missing parent directories. + // HOW: Save to a nested path whose directories do not yet exist and then confirm that + // the file was created successfully. + // WHY: Users should not need to manually prepare directory trees before using config + // commands, especially during first-time setup. const QTemporaryDir tempDir; QVERIFY(tempDir.isValid()); @@ -308,16 +391,22 @@ void ConfigTest::saveConfigCreatesParentDirectory() void ConfigTest::applyConfigValueUpdatesSupportedFields() { + // WHAT: Verify that supported config keys accept valid updates from string input. + // HOW: Apply a series of key/value updates through the CLI-style helper and confirm + // that each target field changes to the expected normalized value. + // WHY: The interactive and command-line config editing flows depend on this helper to + // turn user input into stored settings safely and consistently. AppConfig config = defaultAppConfig(); QString errorMessage; QVERIFY(applyConfigValue(&config, QStringLiteral("shortcut.sequence"), QStringLiteral("Meta+F8"), &errorMessage)); QVERIFY(applyConfigValue(&config, QStringLiteral("audio.sample_rate"), QStringLiteral("48000"), &errorMessage)); QVERIFY(applyConfigValue(&config, QStringLiteral("audio.channels"), QStringLiteral("2"), &errorMessage)); - QVERIFY(applyConfigValue(&config, QStringLiteral("audio.minimum_seconds"), QStringLiteral("0.75"), &errorMessage)); + QVERIFY(applyConfigValue(&config, QStringLiteral("audio.minimum_seconds"), QStringLiteral("0"), &errorMessage)); QVERIFY(applyConfigValue(&config, QStringLiteral("audio.device_id"), QStringLiteral(" test-mic "), &errorMessage)); QVERIFY(applyConfigValue(&config, QStringLiteral("transcriber.model_path"), QStringLiteral(" /tmp/model.bin "), &errorMessage)); QVERIFY(applyConfigValue(&config, QStringLiteral("transcriber.language"), QStringLiteral(" fi "), &errorMessage)); + QVERIFY(applyConfigValue(&config, QStringLiteral("transcriber.language"), QStringLiteral(" Finnish "), &errorMessage)); QVERIFY(applyConfigValue(&config, QStringLiteral("transcriber.language"), QStringLiteral(" auto "), &errorMessage)); QVERIFY(applyConfigValue(&config, QStringLiteral("transcriber.translate"), QStringLiteral("yes"), &errorMessage)); QVERIFY(applyConfigValue(&config, QStringLiteral("transcriber.threads"), QStringLiteral("3"), &errorMessage)); @@ -328,7 +417,7 @@ void ConfigTest::applyConfigValueUpdatesSupportedFields() QCOMPARE(config.shortcut.sequence, QStringLiteral("Meta+F8")); QCOMPARE(config.audio.sampleRate, 48000); QCOMPARE(config.audio.channels, 2); - QCOMPARE(config.audio.minimumSeconds, 0.75); + QCOMPARE(config.audio.minimumSeconds, 0.0); QCOMPARE(config.audio.deviceId, QStringLiteral("test-mic")); QCOMPARE(config.transcriber.modelPath, QStringLiteral("/tmp/model.bin")); QCOMPARE(config.transcriber.language, QStringLiteral("auto")); @@ -340,6 +429,11 @@ void ConfigTest::applyConfigValueUpdatesSupportedFields() void ConfigTest::applyConfigValueRejectsInvalidInputs() { + // WHAT: Verify that invalid config updates are rejected without changing the config. + // HOW: Try empty, unsupported, or out-of-range values and confirm that the helper + // returns an error while the previous valid values remain untouched. + // WHY: A failed update should be safe to attempt, because partial or destructive config + // writes would make CLI editing unreliable and hard to trust. const AppConfig originalConfig = defaultAppConfig(); AppConfig config = originalConfig; QString errorMessage; @@ -359,8 +453,8 @@ void ConfigTest::applyConfigValueRejectsInvalidInputs() QCOMPARE(config.transcriber.translate, originalConfig.transcriber.translate); errorMessage.clear(); - QVERIFY(!applyConfigValue(&config, QStringLiteral("transcriber.language"), QStringLiteral("pirate"), &errorMessage)); - QVERIFY(errorMessage.contains(QStringLiteral("supported Whisper language"))); + QVERIFY(!applyConfigValue(&config, QStringLiteral("transcriber.language"), QStringLiteral(" "), &errorMessage)); + QVERIFY(errorMessage.contains(QStringLiteral("may not be empty"))); QCOMPARE(config.transcriber.language, originalConfig.transcriber.language); errorMessage.clear(); @@ -371,6 +465,11 @@ void ConfigTest::applyConfigValueRejectsInvalidInputs() void ConfigTest::applyConfigValueRejectsUnknownKeys() { + // WHAT: Verify that unknown config keys are rejected. + // HOW: Attempt to update a key that the application does not support and check that + // the helper returns an "Unsupported config key" style error. + // WHY: Rejecting unknown keys prevents silent no-op behavior and makes typos visible + // to users immediately. AppConfig config = defaultAppConfig(); QString errorMessage; @@ -378,6 +477,35 @@ void ConfigTest::applyConfigValueRejectsUnknownKeys() QVERIFY(errorMessage.contains(QStringLiteral("Unsupported config key"))); } +void ConfigTest::applyConfigValueAcceptsBooleanFalseSpellings_data() +{ + QTest::addColumn("value"); + + QTest::newRow("false") << QStringLiteral("false"); + QTest::newRow("zero") << QStringLiteral("0"); + QTest::newRow("no") << QStringLiteral("no"); + QTest::newRow("off") << QStringLiteral("off"); +} + +void ConfigTest::applyConfigValueAcceptsBooleanFalseSpellings() +{ + // WHAT: Verify that supported false-like boolean spellings are accepted for config updates. + // HOW: Apply the same boolean field using a table of accepted false spellings and confirm + // that each variant resolves to `false`. + // WHY: CLI configuration should be forgiving about common boolean input styles so users + // do not have to remember one exact textual representation. + // NOLINTNEXTLINE(misc-const-correctness): QFETCH declares a mutable local by macro design. + QFETCH(QString, value); + + AppConfig config = defaultAppConfig(); + config.transcriber.translate = true; + QString errorMessage; + + QVERIFY(applyConfigValue(&config, QStringLiteral("transcriber.translate"), value, &errorMessage)); + QVERIFY(errorMessage.isEmpty()); + QCOMPARE(config.transcriber.translate, false); +} + QTEST_APPLESS_MAIN(ConfigTest) #include "configtest.moc" diff --git a/tests/daemoncontrolclientservertest.cpp b/tests/daemoncontrolclientservertest.cpp new file mode 100644 index 0000000..5008dda --- /dev/null +++ b/tests/daemoncontrolclientservertest.cpp @@ -0,0 +1,287 @@ +#include "control/daemoncontrolclient.h" +#include "control/daemoncontrolserver.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { + +QString uniqueSocketName() +{ + return QStringLiteral("mutterkey-test-%1").arg(QUuid::createUuid().toString(QUuid::WithoutBraces)); +} + +class SessionFetchWorker final : public QObject +{ + Q_OBJECT + +public: + enum class Operation : std::uint8_t { + FetchStatus, + FetchConfig, + }; + + explicit SessionFetchWorker(QString socketName, Operation operation) + : m_socketName(std::move(socketName)) + , m_operation(operation) + { + } + + [[nodiscard]] const DaemonStatusResult &statusResult() const { return m_statusResult; } + [[nodiscard]] const DaemonConfigResult &configResult() const { return m_configResult; } + +public Q_SLOTS: + void run() + { + const LocalDaemonControlSession session(m_socketName); + if (m_operation == Operation::FetchStatus) { + m_statusResult = session.fetchStatus(2000); + } else { + m_configResult = session.fetchConfig(2000); + } + emit finished(); + } + +Q_SIGNALS: + void finished(); + +private: + QString m_socketName; + Operation m_operation = Operation::FetchStatus; + DaemonStatusResult m_statusResult; + DaemonConfigResult m_configResult; +}; + +class SessionFetchThread final +{ +public: + SessionFetchThread(QString socketName, SessionFetchWorker::Operation operation) + : m_worker(std::move(socketName), operation) + { + m_worker.moveToThread(&m_thread); + } + + ~SessionFetchThread() + { + m_thread.quit(); + m_thread.wait(2000); + } + + SessionFetchThread(const SessionFetchThread &) = delete; + SessionFetchThread &operator=(const SessionFetchThread &) = delete; + SessionFetchThread(SessionFetchThread &&) = delete; + SessionFetchThread &operator=(SessionFetchThread &&) = delete; + + void start() + { + if (m_started) { + return; + } + + QObject::connect(&m_worker, &SessionFetchWorker::finished, &m_thread, &QThread::quit, Qt::UniqueConnection); + m_finishedSpy = std::make_unique(&m_worker, &SessionFetchWorker::finished); + m_thread.start(); + QVERIFY(QMetaObject::invokeMethod(&m_worker, &SessionFetchWorker::run, Qt::QueuedConnection)); + m_started = true; + } + + void wait() + { + QVERIFY(m_started); + QTRY_COMPARE_WITH_TIMEOUT(m_finishedSpy->count(), 1, 2000); + QVERIFY(m_thread.wait(2000)); + } + + void runAndWait() + { + start(); + wait(); + } + + [[nodiscard]] const DaemonStatusResult &statusResult() const { return m_worker.statusResult(); } + [[nodiscard]] const DaemonConfigResult &configResult() const { return m_worker.configResult(); } + +private: + QThread m_thread; + SessionFetchWorker m_worker; + std::unique_ptr m_finishedSpy; + bool m_started = false; +}; + +class DaemonControlClientServerTest final : public QObject +{ + Q_OBJECT + +private slots: + void fetchStatusRoundTripsThroughLocalServer(); + void fetchConfigRoundTripsThroughLocalServer(); + void fetchStatusReportsMismatchedResponseId(); + void fetchStatusReportsMalformedSnapshotPayload(); +}; + +} // namespace + +void DaemonControlClientServerTest::fetchStatusRoundTripsThroughLocalServer() +{ + // WHAT: Verify that the local client can fetch daemon status from the real local server. + // HOW: Start a test server on an injected socket name, fetch status through the real client, + // and confirm that the typed status result contains the expected config-path metadata. + // WHY: This is the smallest end-to-end proof that the client, socket transport, protocol, + // and typed status parsing all work together as one control-plane path. + const QString socketName = uniqueSocketName(); + const QString configPath = QStringLiteral("/tmp/test-mutterkey-config.json"); + DaemonControlServer server(configPath, defaultAppConfig(), nullptr, socketName, nullptr); + + QString errorMessage; + QVERIFY2(server.start(&errorMessage), qPrintable(errorMessage)); + const auto stopServer = qScopeGuard([&server]() { server.stop(); }); + + SessionFetchThread fetchThread(socketName, SessionFetchWorker::Operation::FetchStatus); + fetchThread.runAndWait(); + + const DaemonStatusResult result = fetchThread.statusResult(); + + QVERIFY2(result.success, qPrintable(result.errorMessage)); + QVERIFY(result.errorMessage.isEmpty()); + QVERIFY(result.snapshot.daemonRunning); + QCOMPARE(result.snapshot.configPath, configPath); + QCOMPARE(result.snapshot.configExists, false); + QVERIFY(result.snapshot.serviceDiagnostics.isEmpty()); +} + +void DaemonControlClientServerTest::fetchConfigRoundTripsThroughLocalServer() +{ + // WHAT: Verify that the local client can fetch daemon config from the real local server. + // HOW: Start a test server with a custom config snapshot, fetch config through the real + // client, and compare the parsed snapshot fields with the injected server-side values. + // WHY: This proves the config inspection path works across the real transport boundary, + // which is important for tray and CLI tooling that depend on daemon-owned config state. + const QString socketName = uniqueSocketName(); + const QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + AppConfig config = defaultAppConfig(); + config.shortcut.sequence = QStringLiteral("Meta+F8"); + config.transcriber.modelPath = tempDir.filePath(QStringLiteral("model.bin")); + const QString configPath = tempDir.filePath(QStringLiteral("config.json")); + QFile configFile(configPath); + QVERIFY(configFile.open(QIODevice::WriteOnly | QIODevice::Text)); + configFile.write("{}"); + configFile.close(); + + DaemonControlServer server(configPath, config, nullptr, socketName, nullptr); + QString errorMessage; + QVERIFY2(server.start(&errorMessage), qPrintable(errorMessage)); + const auto stopServer = qScopeGuard([&server]() { server.stop(); }); + + SessionFetchThread fetchThread(socketName, SessionFetchWorker::Operation::FetchConfig); + fetchThread.runAndWait(); + + const DaemonConfigResult result = fetchThread.configResult(); + + QVERIFY2(result.success, qPrintable(result.errorMessage)); + QVERIFY(result.errorMessage.isEmpty()); + QCOMPARE(result.snapshot.configPath, configPath); + QCOMPARE(result.snapshot.configExists, true); + QCOMPARE(result.snapshot.config.shortcut.sequence, QStringLiteral("Meta+F8")); + QCOMPARE(result.snapshot.config.transcriber.modelPath, config.transcriber.modelPath); +} + +void DaemonControlClientServerTest::fetchStatusReportsMismatchedResponseId() +{ + // WHAT: Verify that the local client rejects responses whose request ID does not match. + // HOW: Serve a syntactically valid response from a raw local server but change the echoed + // request ID, then confirm that the client reports a mismatched-response error. + // WHY: Request IDs are the only protection against correlating the wrong response to a + // request, so this check is critical for reliable control-plane behavior. + const QString socketName = uniqueSocketName(); + QLocalServer server; + QLocalServer::removeServer(socketName); + QVERIFY(server.listen(socketName)); + const auto removeSocket = qScopeGuard([&socketName]() { QLocalServer::removeServer(socketName); }); + + SessionFetchThread fetchThread(socketName, SessionFetchWorker::Operation::FetchStatus); + fetchThread.start(); + + QVERIFY(server.waitForNewConnection(1000)); + std::unique_ptr socket(server.nextPendingConnection()); + QVERIFY(socket != nullptr); + QVERIFY(socket->waitForReadyRead(1000)); + + DaemonControlRequest request; + QString parseError; + QVERIFY(parseDaemonControlRequest(socket->readLine(), &request, &parseError)); + + DaemonControlResponse response; + response.requestId = QStringLiteral("different-id"); + response.success = true; + response.result = daemonStatusSnapshotToJsonObject(DaemonStatusSnapshot{ + .daemonRunning = true, + .configPath = QStringLiteral("/tmp/test.json"), + .configExists = false, + .serviceDiagnostics = QJsonObject{}, + }); + QVERIFY(socket->write(serializeDaemonControlResponse(response)) > 0); + QVERIFY(socket->waitForBytesWritten(1000)); + + fetchThread.wait(); + + const DaemonStatusResult result = fetchThread.statusResult(); + + QVERIFY(!result.success); + QCOMPARE(result.errorMessage, QStringLiteral("Mismatched daemon response id")); +} + +void DaemonControlClientServerTest::fetchStatusReportsMalformedSnapshotPayload() +{ + // WHAT: Verify that the local client reports malformed status payloads from the server. + // HOW: Serve a successful protocol response whose result object does not match the typed + // daemon status schema, then confirm that the parsed client result contains that error. + // WHY: Transport success is not enough on its own; the client must still reject broken + // payloads so UI and tooling do not trust invalid daemon state. + const QString socketName = uniqueSocketName(); + QLocalServer server; + QLocalServer::removeServer(socketName); + QVERIFY(server.listen(socketName)); + const auto removeSocket = qScopeGuard([&socketName]() { QLocalServer::removeServer(socketName); }); + + SessionFetchThread fetchThread(socketName, SessionFetchWorker::Operation::FetchStatus); + fetchThread.start(); + + QVERIFY(server.waitForNewConnection(1000)); + std::unique_ptr socket(server.nextPendingConnection()); + QVERIFY(socket != nullptr); + QVERIFY(socket->waitForReadyRead(1000)); + + DaemonControlRequest request; + QString parseError; + QVERIFY(parseDaemonControlRequest(socket->readLine(), &request, &parseError)); + + DaemonControlResponse response; + response.requestId = request.requestId; + response.success = true; + response.result.insert(QStringLiteral("config_path"), QStringLiteral("/tmp/test.json")); + QVERIFY(socket->write(serializeDaemonControlResponse(response)) > 0); + QVERIFY(socket->waitForBytesWritten(1000)); + + fetchThread.wait(); + + const DaemonStatusResult result = fetchThread.statusResult(); + + QVERIFY(!result.success); + QVERIFY(result.errorMessage.contains(QStringLiteral("Malformed daemon status payload"))); +} + +QTEST_GUILESS_MAIN(DaemonControlClientServerTest) + +#include "daemoncontrolclientservertest.moc" diff --git a/tests/daemoncontrolprotocoltest.cpp b/tests/daemoncontrolprotocoltest.cpp index 8b3c4b4..d11ca55 100644 --- a/tests/daemoncontrolprotocoltest.cpp +++ b/tests/daemoncontrolprotocoltest.cpp @@ -11,14 +11,21 @@ class DaemonControlProtocolTest final : public QObject private slots: void requestRoundTrip(); void responseRoundTrip(); - void rejectsUnknownMethod(); - void rejectsMissingRequestId(); + void rejectsInvalidRequests_data(); + void rejectsInvalidRequests(); + void rejectsInvalidResponses_data(); + void rejectsInvalidResponses(); }; } // namespace void DaemonControlProtocolTest::requestRoundTrip() { + // WHAT: Verify that a daemon-control request survives serialize/parse round-tripping. + // HOW: Build a request object, serialize it to protocol text, parse it back, and + // compare the parsed version, request ID, and method with the original values. + // WHY: The request protocol is the contract between local clients and the daemon, so + // round-trip stability is necessary for reliable control messages. DaemonControlRequest request; request.requestId = QStringLiteral("abc123"); request.method = DaemonControlMethod::GetStatus; @@ -33,6 +40,11 @@ void DaemonControlProtocolTest::requestRoundTrip() void DaemonControlProtocolTest::responseRoundTrip() { + // WHAT: Verify that a daemon-control response survives serialize/parse round-tripping. + // HOW: Build a successful response with result data, serialize it, parse it back, and + // confirm that the important fields are preserved. + // WHY: Clients depend on these responses to interpret daemon state correctly, so the + // protocol must preserve data without loss or shape changes. DaemonControlResponse response; response.requestId = QStringLiteral("pong123"); response.success = true; @@ -47,20 +59,90 @@ void DaemonControlProtocolTest::responseRoundTrip() QVERIFY(parsedResponse.result.value(QStringLiteral("daemon_running")).toBool()); } -void DaemonControlProtocolTest::rejectsUnknownMethod() +void DaemonControlProtocolTest::rejectsInvalidRequests_data() { + QTest::addColumn("payload"); + QTest::addColumn("expectedError"); + + QTest::newRow("invalid json") + << QByteArray("{") + << QStringLiteral("Invalid JSON payload"); + QTest::newRow("missing version") + << QByteArray("{\"request_id\":\"x\",\"method\":\"ping\"}\n") + << QStringLiteral("Missing numeric protocol version"); + QTest::newRow("unsupported version") + << QByteArray("{\"version\":2,\"request_id\":\"x\",\"method\":\"ping\"}\n") + << QStringLiteral("Unsupported protocol version"); + QTest::newRow("missing request id") + << QByteArray("{\"version\":1,\"method\":\"ping\"}\n") + << QStringLiteral("Missing non-empty request_id"); + QTest::newRow("blank request id") + << QByteArray("{\"version\":1,\"request_id\":\" \",\"method\":\"ping\"}\n") + << QStringLiteral("Missing non-empty request_id"); + QTest::newRow("unknown method") + << QByteArray("{\"version\":1,\"request_id\":\"x\",\"method\":\"explode\"}\n") + << QStringLiteral("Unsupported daemon control method"); +} + +void DaemonControlProtocolTest::rejectsInvalidRequests() +{ + // WHAT: Verify that malformed or unsupported request payloads are rejected. + // HOW: Parse a table of invalid request payloads and confirm that each one fails with + // the expected error category. + // WHY: The daemon control API should fail loudly on broken input rather than accepting + // ambiguous or unsupported requests. + // NOLINTNEXTLINE(misc-const-correctness): QFETCH declares a mutable local by macro design. + QFETCH(QByteArray, payload); + // NOLINTNEXTLINE(misc-const-correctness): QFETCH declares a mutable local by macro design. + QFETCH(QString, expectedError); + DaemonControlRequest request; QString errorMessage; - QVERIFY(!parseDaemonControlRequest("{\"version\":1,\"request_id\":\"x\",\"method\":\"explode\"}\n", &request, &errorMessage)); - QVERIFY(errorMessage.contains(QStringLiteral("Unsupported daemon control method"))); + QVERIFY(!parseDaemonControlRequest(payload, &request, &errorMessage)); + QVERIFY2(errorMessage.contains(expectedError), qPrintable(errorMessage)); } -void DaemonControlProtocolTest::rejectsMissingRequestId() +void DaemonControlProtocolTest::rejectsInvalidResponses_data() { + QTest::addColumn("payload"); + QTest::addColumn("expectedError"); + + QTest::newRow("missing request id") + << QByteArray("{\"version\":1,\"ok\":true,\"result\":{}}\n") + << QStringLiteral("request_id"); + QTest::newRow("missing ok") + << QByteArray("{\"version\":1,\"request_id\":\"x\",\"result\":{}}\n") + << QStringLiteral("Missing boolean ok field"); + QTest::newRow("success without result") + << QByteArray("{\"version\":1,\"request_id\":\"x\",\"ok\":true}\n") + << QStringLiteral("Successful response is missing result object"); + QTest::newRow("failed without error") + << QByteArray("{\"version\":1,\"request_id\":\"x\",\"ok\":false}\n") + << QStringLiteral("Failed response is missing error text"); + QTest::newRow("blank failed error") + << QByteArray("{\"version\":1,\"request_id\":\"x\",\"ok\":false,\"error\":\" \"}\n") + << QStringLiteral("Failed response is missing error text"); + QTest::newRow("unsupported version") + << QByteArray("{\"version\":9,\"request_id\":\"x\",\"ok\":true,\"result\":{}}\n") + << QStringLiteral("Unsupported protocol version"); +} + +void DaemonControlProtocolTest::rejectsInvalidResponses() +{ + // WHAT: Verify that malformed or unsupported response payloads are rejected. + // HOW: Parse a table of invalid response payloads and confirm that each one fails with + // the expected error category. + // WHY: Client code depends on strict response validation to avoid trusting broken daemon + // replies as if they were valid control-plane state. + // NOLINTNEXTLINE(misc-const-correctness): QFETCH declares a mutable local by macro design. + QFETCH(QByteArray, payload); + // NOLINTNEXTLINE(misc-const-correctness): QFETCH declares a mutable local by macro design. + QFETCH(QString, expectedError); + DaemonControlResponse response; QString errorMessage; - QVERIFY(!parseDaemonControlResponse("{\"version\":1,\"ok\":true,\"result\":{}}\n", &response, &errorMessage)); - QVERIFY(errorMessage.contains(QStringLiteral("request_id"))); + QVERIFY(!parseDaemonControlResponse(payload, &response, &errorMessage)); + QVERIFY2(errorMessage.contains(expectedError), qPrintable(errorMessage)); } QTEST_APPLESS_MAIN(DaemonControlProtocolTest) diff --git a/tests/daemoncontroltypestest.cpp b/tests/daemoncontroltypestest.cpp index ac06325..4b75bbd 100644 --- a/tests/daemoncontroltypestest.cpp +++ b/tests/daemoncontroltypestest.cpp @@ -18,6 +18,11 @@ private slots: void DaemonControlTypesTest::parseStatusSnapshot() { + // WHAT: Verify that a daemon status snapshot can be converted to JSON and back. + // HOW: Build a populated status snapshot, serialize it to a JSON object, parse it + // again, and compare the key fields with the original values. + // WHY: Status snapshots are shown to operators and tray UI code, so stable typed + // parsing is necessary for trustworthy status reporting. DaemonStatusSnapshot input; input.daemonRunning = true; input.configPath = QStringLiteral("/tmp/mutterkey.json"); @@ -34,6 +39,11 @@ void DaemonControlTypesTest::parseStatusSnapshot() void DaemonControlTypesTest::parseConfigSnapshot() { + // WHAT: Verify that a daemon config snapshot can be converted to JSON and back. + // HOW: Build a config snapshot with representative values, serialize it, parse it, + // and check that the parsed snapshot still contains the same configuration data. + // WHY: This protects the contract used to inspect daemon configuration remotely, which + // helps operators and UI surfaces present accurate settings. DaemonConfigSnapshot input; input.configPath = QStringLiteral("/tmp/mutterkey.json"); input.configExists = true; @@ -50,6 +60,11 @@ void DaemonControlTypesTest::parseConfigSnapshot() void DaemonControlTypesTest::rejectMalformedStatusSnapshot() { + // WHAT: Verify that malformed status payloads are rejected. + // HOW: Attempt to parse a JSON object that is missing required status fields and check + // that parsing fails with a malformed-payload error. + // WHY: Rejecting incomplete status data prevents the rest of the application from + // treating broken control-plane messages as trustworthy state. DaemonStatusSnapshot parsed; QString errorMessage; QVERIFY(!parseDaemonStatusSnapshot(QJsonObject{{QStringLiteral("config_path"), QStringLiteral("/tmp/x")}}, &parsed, &errorMessage)); diff --git a/tests/recordingnormalizertest.cpp b/tests/recordingnormalizertest.cpp index 3d3a97b..d3ac4bd 100644 --- a/tests/recordingnormalizertest.cpp +++ b/tests/recordingnormalizertest.cpp @@ -37,14 +37,21 @@ private slots: void normalizeForWhisperDownmixesStereoInput(); void normalizeForWhisperResamplesMonoInput(); void normalizeForWhisperAcceptsAlreadyNormalizedMonoInput(); + void normalizeForWhisperRejectsEmptyRecording(); void normalizeForWhisperRejectsInvalidSampleFormat(); void normalizeForWhisperRejectsIncompleteFrames(); + void normalizeForWhisperTruncatesTailBytesAfterCompleteFrames(); }; } // namespace void RecordingNormalizerTest::normalizeForWhisperDownmixesStereoInput() { + // WHAT: Verify that stereo PCM input is downmixed to Whisper's mono format. + // HOW: Feed the normalizer a short stereo recording with matching left/right samples + // and check that the output becomes mono float samples with the expected amplitudes. + // WHY: Mutterkey may capture audio in more than one channel, but Whisper expects mono + // input, so the conversion step must preserve meaning while changing format. QAudioFormat format; format.setSampleRate(16000); format.setChannelCount(2); @@ -68,6 +75,11 @@ void RecordingNormalizerTest::normalizeForWhisperDownmixesStereoInput() void RecordingNormalizerTest::normalizeForWhisperResamplesMonoInput() { + // WHAT: Verify that mono input is resampled to Whisper's required 16 kHz rate. + // HOW: Provide an 8 kHz recording, run normalization, and check that the output sample + // rate and interpolated sample values match the expected 16 kHz result. + // WHY: Recordings can arrive at device-native rates, and transcription quality depends + // on the model receiving audio in the format it expects. QAudioFormat format; format.setSampleRate(8000); format.setChannelCount(1); @@ -93,6 +105,11 @@ void RecordingNormalizerTest::normalizeForWhisperResamplesMonoInput() void RecordingNormalizerTest::normalizeForWhisperAcceptsAlreadyNormalizedMonoInput() { + // WHAT: Verify that already compatible mono 16 kHz PCM is accepted as-is. + // HOW: Normalize an input that already matches Whisper's required structure and confirm + // that the output stays mono, 16 kHz, and correctly scaled into float sample values. + // WHY: The normalizer should not damage good input, because this is the fast path for + // devices that already capture in the preferred format. QAudioFormat format; format.setSampleRate(16000); format.setChannelCount(1); @@ -115,8 +132,30 @@ void RecordingNormalizerTest::normalizeForWhisperAcceptsAlreadyNormalizedMonoInp QVERIFY(std::abs(normalizedAudio.samples.at(2) - 0.9999695f) < 0.0001f); } +void RecordingNormalizerTest::normalizeForWhisperRejectsEmptyRecording() +{ + // WHAT: Verify that an empty recording is rejected before any normalization work starts. + // HOW: Pass a default-constructed recording and confirm that normalization fails with an + // explicit "Recording is empty" error. + // WHY: Empty capture data is a common early failure mode, and rejecting it clearly keeps + // later audio-format errors from hiding the real problem. + const RecordingNormalizer normalizer; + NormalizedAudio normalizedAudio; + QString errorMessage; + const bool ok = normalizer.normalizeForWhisper(Recording{}, &normalizedAudio, &errorMessage); + + QVERIFY(!ok); + QCOMPARE(errorMessage, QStringLiteral("Recording is empty")); + QVERIFY(!normalizedAudio.isValid()); +} + void RecordingNormalizerTest::normalizeForWhisperRejectsInvalidSampleFormat() { + // WHAT: Verify that unsupported sample formats are rejected. + // HOW: Pass in a recording that uses floating-point samples instead of 16-bit PCM and + // confirm that normalization fails with the expected error. + // WHY: Whisper integration currently supports a specific capture format, and rejecting + // incompatible data early avoids undefined audio conversion behavior. QAudioFormat format; format.setSampleRate(16000); format.setChannelCount(1); @@ -139,6 +178,11 @@ void RecordingNormalizerTest::normalizeForWhisperRejectsInvalidSampleFormat() void RecordingNormalizerTest::normalizeForWhisperRejectsIncompleteFrames() { + // WHAT: Verify that truncated PCM frame data is rejected. + // HOW: Provide a byte buffer whose size does not contain a whole stereo frame and check + // that normalization fails with an incomplete-frame error. + // WHY: Partial frame data usually means corrupted capture or transport data, and using + // it would make downstream audio interpretation unreliable. QAudioFormat format; format.setSampleRate(16000); format.setChannelCount(2); @@ -159,6 +203,38 @@ void RecordingNormalizerTest::normalizeForWhisperRejectsIncompleteFrames() QVERIFY(!normalizedAudio.isValid()); } +void RecordingNormalizerTest::normalizeForWhisperTruncatesTailBytesAfterCompleteFrames() +{ + // WHAT: Verify that extra tail bytes after a complete frame are ignored rather than breaking normalization. + // HOW: Build stereo PCM data containing one full frame plus one extra byte, normalize it, + // and confirm that the result contains exactly one valid downmixed sample. + // WHY: Real byte streams can end with leftover bytes, and the normalizer is expected to + // salvage the valid frames instead of failing once it has enough complete audio data. + QAudioFormat format; + format.setSampleRate(16000); + format.setChannelCount(2); + format.setSampleFormat(QAudioFormat::Int16); + + Recording recording; + recording.format = format; + appendSample(&recording.pcmData, 16384); + appendSample(&recording.pcmData, -16384); + recording.pcmData.append('\0'); + recording.durationSeconds = 0.25; + + const RecordingNormalizer normalizer; + NormalizedAudio normalizedAudio; + QString errorMessage; + const bool ok = normalizer.normalizeForWhisper(recording, &normalizedAudio, &errorMessage); + + QVERIFY2(ok, qPrintable(errorMessage)); + QVERIFY(errorMessage.isEmpty()); + QCOMPARE(normalizedAudio.sampleRate, 16000); + QCOMPARE(normalizedAudio.channels, 1); + QCOMPARE(normalizedAudio.samples.size(), size_t{1}); + QVERIFY(std::abs(normalizedAudio.samples.at(0) - 0.0f) < 0.0001f); +} + QTEST_APPLESS_MAIN(RecordingNormalizerTest) #include "recordingnormalizertest.moc" diff --git a/tests/transcriptionworkertest.cpp b/tests/transcriptionworkertest.cpp index ee3d5a8..a1164af 100644 --- a/tests/transcriptionworkertest.cpp +++ b/tests/transcriptionworkertest.cpp @@ -1,6 +1,7 @@ #include "audio/recording.h" #include "transcription/transcriptionengine.h" #include "transcription/transcriptionworker.h" +#include "transcription/whispercpptranscriber.h" #include #include @@ -10,6 +11,18 @@ namespace { +BackendCapabilities fakeCapabilities() +{ + return BackendCapabilities{ + .backendName = QStringLiteral("fake"), + .runtimeDescription = QStringLiteral("fake runtime"), + .supportedLanguages = {QStringLiteral("en"), QStringLiteral("fi")}, + .supportsAutoLanguage = true, + .supportsTranslation = true, + .supportsWarmup = true, + }; +} + class FakeTranscriptionSession final : public TranscriptionSession { public: @@ -23,12 +36,15 @@ class FakeTranscriptionSession final : public TranscriptionSession return QStringLiteral("fake"); } - bool warmup(QString *errorMessage) override + bool warmup(RuntimeError *error) override { - if (!m_warmupError.isEmpty() && errorMessage != nullptr) { - *errorMessage = m_warmupError; + if (!m_warmupError.isOk()) { + if (error != nullptr) { + *error = m_warmupError; + } + return false; } - return m_warmupError.isEmpty(); + return true; } [[nodiscard]] TranscriptionResult transcribe(const Recording &) override @@ -36,14 +52,43 @@ class FakeTranscriptionSession final : public TranscriptionSession return m_result; } - void setWarmupError(QString warmupError) + void setWarmupError(RuntimeError warmupError) { m_warmupError = std::move(warmupError); } private: TranscriptionResult m_result; - QString m_warmupError; + RuntimeError m_warmupError; +}; + +class FakeTranscriptionEngine final : public TranscriptionEngine +{ +public: + explicit FakeTranscriptionEngine(std::unique_ptr session) + : m_session(std::move(session)) + { + } + + [[nodiscard]] BackendCapabilities capabilities() const override + { + return fakeCapabilities(); + } + + [[nodiscard]] std::unique_ptr createSession() const override + { + ++m_createSessionCalls; + return std::move(m_session); + } + + [[nodiscard]] int createSessionCalls() const + { + return m_createSessionCalls; + } + +private: + mutable int m_createSessionCalls = 0; + mutable std::unique_ptr m_session; }; class TranscriptionWorkerTest final : public QObject @@ -51,20 +96,29 @@ class TranscriptionWorkerTest final : public QObject Q_OBJECT private slots: + void initTestCase(); void reportsInjectedBackendName(); void emitsReadySignalForSuccessfulTranscription(); void emitsFailureSignalForFailedTranscription(); void surfacesWarmupFailure(); + void lazilyCreatesSessionFromInjectedEngine(); + void whisperRuntimeRejectsUnsupportedLanguage(); }; } // namespace +void TranscriptionWorkerTest::initTestCase() +{ + qRegisterMetaType("RuntimeError"); +} + void TranscriptionWorkerTest::reportsInjectedBackendName() { auto session = std::make_unique(TranscriptionResult{.success = true, .text = {}}); const TranscriptionWorker worker(std::move(session)); QCOMPARE(worker.backendName(), QStringLiteral("fake")); + QVERIFY(worker.capabilities().runtimeDescription.isEmpty()); } void TranscriptionWorkerTest::emitsReadySignalForSuccessfulTranscription() @@ -84,8 +138,14 @@ void TranscriptionWorkerTest::emitsReadySignalForSuccessfulTranscription() void TranscriptionWorkerTest::emitsFailureSignalForFailedTranscription() { - auto session = std::make_unique( - TranscriptionResult{.success = false, .text = {}, .error = QStringLiteral("decode failed")}); + auto session = std::make_unique(TranscriptionResult{ + .success = false, + .text = {}, + .error = RuntimeError{ + .code = RuntimeErrorCode::DecodeFailed, + .message = QStringLiteral("decode failed"), + }, + }); TranscriptionWorker worker(std::move(session)); const QSignalSpy readySpy(&worker, &TranscriptionWorker::transcriptionReady); const QSignalSpy failedSpy(&worker, &TranscriptionWorker::transcriptionFailed); @@ -94,18 +154,56 @@ void TranscriptionWorkerTest::emitsFailureSignalForFailedTranscription() QCOMPARE(readySpy.count(), 0); QCOMPARE(failedSpy.count(), 1); - QCOMPARE(failedSpy.at(0).at(0).toString(), QStringLiteral("decode failed")); + const auto error = failedSpy.at(0).at(0).value(); + QCOMPARE(error.code, RuntimeErrorCode::DecodeFailed); + QCOMPARE(error.message, QStringLiteral("decode failed")); } void TranscriptionWorkerTest::surfacesWarmupFailure() { auto session = std::make_unique(TranscriptionResult{.success = true, .text = {}}); - session->setWarmupError(QStringLiteral("model unavailable")); + session->setWarmupError(RuntimeError{ + .code = RuntimeErrorCode::ModelLoadFailed, + .message = QStringLiteral("model unavailable"), + }); TranscriptionWorker worker(std::move(session)); - QString errorMessage; - QVERIFY(!worker.warmup(&errorMessage)); - QCOMPARE(errorMessage, QStringLiteral("model unavailable")); + RuntimeError error; + QVERIFY(!worker.warmup(&error)); + QCOMPARE(error.code, RuntimeErrorCode::ModelLoadFailed); + QCOMPARE(error.message, QStringLiteral("model unavailable")); +} + +void TranscriptionWorkerTest::lazilyCreatesSessionFromInjectedEngine() +{ + auto session = + std::make_unique(TranscriptionResult{.success = true, .text = QStringLiteral("engine path")}); + const std::shared_ptr engine = std::make_shared(std::move(session)); + TranscriptionWorker worker(engine); + const QSignalSpy readySpy(&worker, &TranscriptionWorker::transcriptionReady); + + QCOMPARE(engine->createSessionCalls(), 0); + QCOMPARE(worker.backendName(), QStringLiteral("fake")); + + worker.transcribe(Recording{}); + + QCOMPARE(engine->createSessionCalls(), 1); + QCOMPARE(readySpy.count(), 1); + QCOMPARE(readySpy.at(0).at(0).toString(), QStringLiteral("engine path")); +} + +void TranscriptionWorkerTest::whisperRuntimeRejectsUnsupportedLanguage() +{ + TranscriberConfig config; + config.modelPath = QStringLiteral("/tmp/unused.bin"); + config.language = QStringLiteral("pirate"); + WhisperCppTranscriber transcriber(config); + + const TranscriptionResult result = transcriber.transcribe(Recording{}); + + QVERIFY(!result.success); + QCOMPARE(result.error.code, RuntimeErrorCode::UnsupportedLanguage); + QVERIFY(result.error.message.contains(QStringLiteral("pirate"))); } QTEST_APPLESS_MAIN(TranscriptionWorkerTest) diff --git a/tests/traystatuswindowtest.cpp b/tests/traystatuswindowtest.cpp index 4e94f08..ab0d5a7 100644 --- a/tests/traystatuswindowtest.cpp +++ b/tests/traystatuswindowtest.cpp @@ -39,12 +39,19 @@ private slots: void refreshShowsOfflineStateWhenTransportFails(); void refreshPopulatesValuesOnSuccessfulResponses(); void refreshShowsOfflineStateWhenConfigRequestFails(); + void refreshUpdatesFromOfflineToConnected(); + void refreshUpdatesFromConnectedToOffline(); }; } // namespace void TrayStatusWindowTest::refreshShowsOfflineStateWhenTransportFails() { + // WHAT: Verify that the tray status window shows an offline state when status transport fails. + // HOW: Make the fake daemon session return a connection error, construct the window, + // and confirm that the visible labels and JSON panel show the unavailable state. + // WHY: When the daemon cannot be reached, the UI must communicate that clearly so users + // do not mistake a transport problem for a healthy but idle daemon. FakeDaemonControlSession client; DaemonStatusResult statusResult; statusResult.errorMessage = QStringLiteral("Connection refused"); @@ -67,6 +74,11 @@ void TrayStatusWindowTest::refreshShowsOfflineStateWhenTransportFails() void TrayStatusWindowTest::refreshPopulatesValuesOnSuccessfulResponses() { + // WHAT: Verify that the tray status window shows live values after successful responses. + // HOW: Return successful fake status and config snapshots, construct the window, and + // check that the key labels and JSON view contain the expected values. + // WHY: This is the main inspection surface for the tray UI, so it must faithfully show + // what the daemon reports instead of stale or placeholder information. FakeDaemonControlSession client; DaemonStatusResult statusResult; @@ -108,6 +120,11 @@ void TrayStatusWindowTest::refreshPopulatesValuesOnSuccessfulResponses() void TrayStatusWindowTest::refreshShowsOfflineStateWhenConfigRequestFails() { + // WHAT: Verify that the tray status window falls back to an offline state when config loading fails. + // HOW: Return a successful status fetch but a failed config fetch, then confirm that the + // window marks the daemon as unavailable and exposes the config error to the user. + // WHY: A partial control-plane failure still leaves the UI without trustworthy state, so + // the window should prefer an explicit problem state over pretending everything is healthy. FakeDaemonControlSession client; DaemonStatusResult statusResult; @@ -131,6 +148,90 @@ void TrayStatusWindowTest::refreshShowsOfflineStateWhenConfigRequestFails() QVERIFY(statusJsonView->toPlainText().contains(QStringLiteral("Config unavailable"))); } +void TrayStatusWindowTest::refreshUpdatesFromOfflineToConnected() +{ + // WHAT: Verify that a manual refresh can move the tray status window from offline to connected. + // HOW: Construct the window with an initial transport failure, then swap the fake session + // to successful status and config responses, call refresh, and check the visible fields. + // WHY: The tray window is a live status surface, so it must recover cleanly after the + // daemon becomes available instead of staying stuck in its initial error state. + FakeDaemonControlSession client; + client.setStatusResult(DaemonStatusResult{.success = false, .snapshot = {}, .errorMessage = QStringLiteral("Connection refused")}); + + TrayStatusWindow window(&client); + + DaemonStatusResult statusResult; + statusResult.success = true; + statusResult.snapshot.daemonRunning = true; + statusResult.snapshot.configPath = QStringLiteral("/tmp/mutterkey.json"); + statusResult.snapshot.configExists = true; + statusResult.snapshot.serviceDiagnostics.insert(QStringLiteral("daemon_running"), true); + client.setStatusResult(statusResult); + + DaemonConfigResult configResult; + configResult.success = true; + configResult.snapshot.configPath = QStringLiteral("/tmp/mutterkey.json"); + configResult.snapshot.configExists = true; + configResult.snapshot.config.shortcut.sequence = QStringLiteral("Meta+F8"); + configResult.snapshot.config.transcriber.modelPath = QStringLiteral("/tmp/model.bin"); + client.setConfigResult(configResult); + + window.refresh(); + + auto *connectionValue = window.findChild(QStringLiteral("connectionValue")); + auto *configPathValue = window.findChild(QStringLiteral("configPathValue")); + auto *statusJsonView = window.findChild(QStringLiteral("statusJsonView")); + + QVERIFY(connectionValue != nullptr); + QVERIFY(configPathValue != nullptr); + QVERIFY(statusJsonView != nullptr); + QCOMPARE(connectionValue->text(), QStringLiteral("Connected")); + QCOMPARE(configPathValue->text(), QStringLiteral("/tmp/mutterkey.json")); + QVERIFY(statusJsonView->toPlainText().contains(QStringLiteral("daemon_running"))); +} + +void TrayStatusWindowTest::refreshUpdatesFromConnectedToOffline() +{ + // WHAT: Verify that a manual refresh can move the tray status window from connected to offline. + // HOW: Construct the window with successful status and config responses, then switch the + // fake session to a failure response, call refresh, and confirm that the offline state wins. + // WHY: Live status views must degrade cleanly when the daemon disappears so users are not + // left looking at stale data that appears current. + FakeDaemonControlSession client; + + DaemonStatusResult statusResult; + statusResult.success = true; + statusResult.snapshot.daemonRunning = true; + statusResult.snapshot.configPath = QStringLiteral("/tmp/mutterkey.json"); + statusResult.snapshot.configExists = true; + statusResult.snapshot.serviceDiagnostics.insert(QStringLiteral("daemon_running"), true); + client.setStatusResult(statusResult); + + DaemonConfigResult configResult; + configResult.success = true; + configResult.snapshot.configPath = QStringLiteral("/tmp/mutterkey.json"); + configResult.snapshot.configExists = true; + configResult.snapshot.config.shortcut.sequence = QStringLiteral("Meta+F8"); + configResult.snapshot.config.transcriber.modelPath = QStringLiteral("/tmp/model.bin"); + client.setConfigResult(configResult); + + TrayStatusWindow window(&client); + + client.setStatusResult(DaemonStatusResult{.success = false, .snapshot = {}, .errorMessage = QStringLiteral("Connection refused")}); + window.refresh(); + + auto *connectionValue = window.findChild(QStringLiteral("connectionValue")); + auto *configPathValue = window.findChild(QStringLiteral("configPathValue")); + auto *statusJsonView = window.findChild(QStringLiteral("statusJsonView")); + + QVERIFY(connectionValue != nullptr); + QVERIFY(configPathValue != nullptr); + QVERIFY(statusJsonView != nullptr); + QCOMPARE(connectionValue->text(), QStringLiteral("Daemon unavailable")); + QCOMPARE(configPathValue->text(), QStringLiteral("-")); + QVERIFY(statusJsonView->toPlainText().contains(QStringLiteral("Connection refused"))); +} + QTEST_MAIN(TrayStatusWindowTest) #include "traystatuswindowtest.moc"