feat(runtime): integrate backend controls and guarded paced text delivery

This commit is contained in:
baketnk committed 2026-09-24 23:23:11 -04:00
1 parent 1321ca8316
commit 9829d0611d
26 files changed
+2238 -399

No files matched your search

+38 -4
View File
@@ -1,7 +1,9 @@
if(NOT DEFINED APP OR NOT DEFINED EXPECTED_VERSION)
message(FATAL_ERROR "APP and EXPECTED_VERSION are required")
endif()
if(NOT EXPECTED_VERSION MATCHES "^[0-9]+\\.[0-9]+\\.[1-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]$")
message(FATAL_ERROR "Invalid numeric build version: ${EXPECTED_VERSION}")
endif()
foreach(mode IN ITEMS default help version invalid extra)
set(args)
set(expected_exit 0)
@@ -22,14 +24,46 @@ foreach(mode IN ITEMS default help version invalid extra)
message(FATAL_ERROR "${mode}: exit ${result}, expected ${expected_exit}: ${error}")
endif()
if(mode STREQUAL "version")
if(NOT output STREQUAL "frameyap ${EXPECTED_VERSION}\n")
string(REPLACE "\n" ";" lines "${output}")
list(GET lines 0 first_line)
if(NOT first_line STREQUAL "frameyap ${EXPECTED_VERSION}")
message(FATAL_ERROR "Unexpected version: ${output}")
endif()
list(LENGTH lines count)
if(count GREATER 1)
list(GET lines 1 second_line)
if(NOT second_line MATCHES "^git [0-9a-f]+( \\(uncommitted changes\\))?$")
message(FATAL_ERROR "Unexpected developer information: ${output}")
endif()
endif()
if(count GREATER 2)
list(GET lines 2 trailing_line)
if(NOT trailing_line STREQUAL "" OR count GREATER 3)
message(FATAL_ERROR "Extra version output: ${output}")
endif()
endif()
elseif(expected_exit EQUAL 2)
if(NOT error MATCHES "Unsupported arguments")
message(FATAL_ERROR "Missing rejection diagnostic")
endif()
elseif(NOT output MATCHES "No device access unless")
message(FATAL_ERROR "Missing explicit runtime opt-in status")
elseif(NOT output MATCHES "No device access unless" OR
NOT output MATCHES "--model-store DIR" OR NOT output MATCHES "manifest-managed Models UI")
message(FATAL_ERROR "Missing explicit runtime opt-in or generic backend help")
endif()
endforeach()
# A namesake worker outside the installation root is custom, even if its
# basename is worker.py. Reject this before runtime setup (on offline builds,
# before the generic "runtime disabled" diagnostic).
foreach(extra IN ITEMS "--backend;redux" "")
set(args --run --assets /app/assets/ --worker /other/worker.py)
if(NOT extra STREQUAL "")
list(APPEND args ${extra})
endif()
execute_process(COMMAND "${APP}" ${args}
RESULT_VARIABLE result OUTPUT_VARIABLE output ERROR_VARIABLE error)
if(NOT result EQUAL 2 OR NOT error MATCHES "custom --worker" OR
error MATCHES "runtime disabled" OR NOT output STREQUAL "")
message(FATAL_ERROR "Custom worker bypassed syntax validation: ${result}: ${error}")
endif()
endforeach()
+47
View File
@@ -0,0 +1,47 @@
"""Verify build metadata suppression without touching the repository's tags/history."""
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
class GitVersionTest(unittest.TestCase):
def test_clean_matching_tag_suppresses_git_line(self):
if not shutil.which('git'):
self.skipTest('Git unavailable')
# Isolate only CMake's version block in a tiny, disposable Git tree.
source = (ROOT / 'CMakeLists.txt').read_text()
header = source.split('message(STATUS "FrameYap build version:', 1)[0]
version = '2.3.202601020304'
with tempfile.TemporaryDirectory(prefix='frameyap-tag-test-') as temp:
root = Path(temp)
(root / 'CMakeLists.txt').write_text(header + '\nfile(WRITE "${CMAKE_CURRENT_BINARY_DIR}/git-info.txt" "${FRAMEYAP_GIT_INFO}")\n')
# CMake's source-tree dirty check includes untracked files. Ignore
# only the disposable build directory in this mini repository.
(root / '.gitignore').write_text('build/\n')
def git(*args):
subprocess.run(['git', '-C', str(root), *args], check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10)
def configure():
subprocess.run(['cmake', '-S', str(root), '-B', str(root / 'build'),
'-DFRAMEYAP_VERSION=' + version], check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30)
return (root / 'build/git-info.txt').read_text()
git('init', '-q')
git('add', 'CMakeLists.txt', '.gitignore')
git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid',
'commit', '-qm', 'fixture')
git('tag', 'v' + version)
self.assertEqual(configure(), '')
(root / 'dirty.txt').write_text('local change\n')
self.assertRegex(configure(), r'^git [0-9a-f]+ \(uncommitted changes\)$')
(root / 'dirty.txt').unlink()
git('tag', '-d', 'v' + version)
self.assertRegex(configure(), r'^git [0-9a-f]+$')
if __name__ == '__main__':
unittest.main()
+80
View File
@@ -0,0 +1,80 @@
#include "cli.hpp"
#include <cassert>
#include <initializer_list>
#include <string>
#include <vector>
using namespace frameyap;
namespace {
CliOptions parse(std::initializer_list<const char*> args) {
std::vector<const char*> argv{"frameyap"};
argv.insert(argv.end(), args.begin(), args.end());
return parse_cli(static_cast<int>(argv.size()), argv.data());
}
bool rejects(std::initializer_list<const char*> args) {
try { parse(args); } catch (const CliError&) { return true; }
return false;
}
}
int main() {
assert(parse({}).mode == CliMode::Help);
assert(parse({"--help"}).mode == CliMode::Help);
assert(parse({"--version"}).mode == CliMode::Version);
assert(parse({"--check-input", "--socket", "display-0"}).socket == "display-0");
auto run = parse({"--run", "--assets", "/app/assets/", "--model", "/model",
"--worker", "/app/python/frameyap/worker.py",
"--python", "/python", "--threads", "4", "--head"});
assert(run.mode == CliMode::Run && run.head && run.threads == "4" &&
is_managed_worker(run.assets, run.worker));
assert(asset_root("/app/assets/") == "/app");
assert(asset_root("/app/assets") == "/app");
assert(is_managed_worker("/app/assets/", "/app/python/frameyap/./worker.py"));
assert(is_managed_worker("/app/assets", "/app/python/frameyap/worker.py"));
assert(!is_managed_worker("/app/assets/", "/elsewhere/worker.py"));
auto generic = parse({"--run", "--assets", "/assets", "--backend", "redux",
"--manifest-dir", "/models/manifests", "--model-store", "/models/store"});
assert(generic.mode == CliMode::Run && generic.worker.empty() && generic.model.empty());
assert(generic.backend == "redux" && generic.manifest_dir == "/models/manifests" &&
generic.model_store == "/models/store");
assert(parse({"--run", "--assets", "/assets"}).worker.empty());
assert(parse({"--run", "--assets", "/app/assets/", "--worker", "/app/python/frameyap/worker.py",
"--backend", "redux"}).model.empty());
assert(parse({"--run", "--assets", "/assets", "--worker", "/tmp/custom.py", "--model", "/weights"}).model == "/weights");
assert(parse({"--run", "--assets", "/assets", "--backend", "a_1-"}).backend == "a_1-");
assert(parse({"--register", "file", "--autostart"}).autostart);
assert(parse({"--unregister", "file"}).manifest == "file");
assert(parse({"--check-model", "redux", "--model-dir", "/tmp/m", "--json"}).json);
assert(parse({"--list-models", "--manifest-dir", "/tmp/manifests"}).manifest_dir == "/tmp/manifests");
for (auto args : {
std::vector<const char*>{"--help", "--version"}, {"--record"}, {"--run", "--threads", "3"},
{"--run", "--head", "--mount", "world"}, {"--check-input", "--assets", "/tmp"},
{"--check-overlay", "--socket", "test"}, {"--register"}, {"--check-model", "redux"},
{"--check-model", "--json"}, {"--list-models", "--model-dir"},
{"--list-models", "--json", "--json"}, {"--run", "--assets", "a", "--assets", "b"},
{"--run", "--assets", "--font"}, {"--unregister", "file", "--autostart"},
{"--list-models", "--check-model", "redux"}, {"--run", "--model-dir", "a"},
{"--list-models", "--backend", "redux"}, {"--check-overlay", "--model-store", "/tmp"},
{"--run", "--assets", "a", "--worker", "custom.py"},
{"--run", "--assets", "a", "--worker", "custom.py", "--model", "m", "--backend", "redux"},
{"--run", "--assets", "/app/assets/", "--worker", "/other/worker.py"},
{"--run", "--assets", "/app/assets/", "--worker", "/other/worker.py", "--backend", "redux"},
{"--run", "--assets", "/app/assets/", "--worker", "/other/worker.py", "--model", "m", "--backend", "redux"},
{"--run", "--assets", "/app/assets/", "--worker", "worker.py"},
{"--run", "--worker", "/app/python/frameyap/worker.py", "--backend", "redux"},
{"--run", "--backend", "../redux"}, {"--run", "--backend", "Redux"},
{"--run", "--backend", "a.b"}, {"--run", "--backend", "-bad"},
{"--run", "--backend", "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvw"},
{"--run", "--model-store", "relative"}, {"--run", "--manifest-dir", "relative"},
{"--run", "--manifest-dir", "/tmp/../etc"}, {"--run", "--model-store", "/tmp/./files"},
{"--run", "--model-store", "/tmp/\nfiles"}, {"--run", "--backend", "redux", "--backend", "redux"},
{"--run", "--model-store"}, {"--run", "--model-store", "/store", "--model-store", "/other"},
{"--check-model", "Redux", "--model-dir", "/models"}
}) {
std::vector<const char*> argv{"frameyap"};
argv.insert(argv.end(), args.begin(), args.end());
bool failed = false;
try { parse_cli(static_cast<int>(argv.size()), argv.data()); }
catch (const CliError&) { failed = true; }
assert(failed);
}
assert(rejects({"--version", "extra"}));
}
+377
View File
@@ -0,0 +1,377 @@
#include "controller.hpp"
#include <cassert>
#include <chrono>
#include <deque>
#include <stdexcept>
#include <string>
using namespace frameyap;
namespace {
struct AudioFake final : ControllerAudio {
bool opened = false, timeout = false;
int prepares = 0, closes = 0, starts = 0, polls = 0, finishes = 0;
size_t samples = 3200;
bool open() const override { return opened; }
void prepare() override { opened = true; ++prepares; }
void start() override { assert(opened); ++starts; }
bool poll() override { ++polls; return timeout; }
std::vector<float> finish() override { ++finishes; timeout = false; return std::vector<float>(samples); }
void cancel() override {}
void close() override { if (opened) ++closes; opened = false; }
int seconds() const override { return 0; }
};
struct WorkerFake final : ControllerWorker {
bool loaded = false;
int starts = 0, stops = 0, submissions = 0;
bool debug = false;
uint64_t request = 0;
std::deque<WorkerReply> replies;
void start(bool advanced) override { loaded = true; ++starts; debug = advanced; }
bool ready() const override { return loaded; }
void submit(uint64_t id, const std::vector<float>& pcm) override {
assert(loaded && pcm.size() == 3200); request = id; ++submissions;
}
std::optional<WorkerReply> poll() override {
if (replies.empty()) return std::nullopt;
auto result = replies.front(); replies.pop_front(); return result;
}
void stop() override { loaded = false; ++stops; replies.clear(); }
};
struct FocusState { bool armed = true, valid = true; int checks = 0, destroyed = 0; };
struct FocusFake final : ControllerFocus {
explicit FocusFake(FocusState& state) : state(state) {}
~FocusFake() override { ++state.destroyed; }
bool arm() override { return state.armed; }
bool valid() override { ++state.checks; return state.valid; }
FocusState& state;
};
struct InputState {
int text = 0, enter = 0, acquisitions = 0;
bool unavailable = false, fail_text = false, lose_focus_on_acquire = false;
std::string sent;
std::vector<std::string> events;
};
struct Lease final : DeliveryLease {
explicit Lease(InputState& state) : state(state) {}
void text(const std::string& literal) override {
++state.text; state.sent = literal; state.events.push_back(literal);
if (state.fail_text) throw std::runtime_error("ambiguous text send");
}
void enter() override { ++state.enter; state.events.push_back("<Enter>"); }
InputState& state;
};
struct Fixture {
AudioFake audio;
WorkerFake worker;
FocusState focus;
InputState input;
DeliveryFactory acquire() {
return [this]() -> std::unique_ptr<DeliveryLease> {
++input.acquisitions;
if (input.lose_focus_on_acquire) focus.valid = false;
if (input.unavailable) throw std::runtime_error("input unavailable");
return std::make_unique<Lease>(input);
};
}
Controller make(bool automatic = false, bool close_mic = false, PacedDelivery* paced = nullptr) {
return Controller(audio, worker, acquire(),
[this]() -> std::unique_ptr<ControllerFocus> { return std::make_unique<FocusFake>(focus); },
{"Hi", "Next"}, automatic, false, close_mic, paced);
}
};
void ready(Controller& c) { c.initialize(); c.tick(); assert(c.state() == State::Ready); }
void release(Controller& c, Fixture& f) {
c.action(UiAction::BeginRecord);
assert(c.state() == State::Recording);
c.action(UiAction::EndRecord);
assert(c.state() == State::Transcribing && f.worker.submissions > 0);
}
void result(Controller& c, Fixture& f, std::string text) {
f.worker.replies.push_back({f.worker.request, std::move(text), {}});
c.tick();
}
}
int main() {
{
Fixture f; auto c = f.make(false); ready(c);
release(c, f); result(c, f, "pending preview");
assert(c.state() == State::Review);
c.backend_changed(false, "Model not installed");
assert(c.state() == State::Error && !c.panel().record_available);
assert(c.panel().transcript.empty() && !f.worker.loaded && !f.audio.opened);
int previous_starts = f.worker.starts;
c.action(UiAction::BeginRecord);
assert(f.worker.starts == previous_starts && f.input.text == 0);
c.action(UiAction::Insert); assert(f.input.text == 0);
c.backend_changed(true); c.tick();
assert(c.state() == State::Ready && f.worker.starts == previous_starts + 1);
release(c, f);
c.backend_changed(false);
f.worker.replies.push_back({f.worker.request, "stale", {}});
c.tick(); assert(c.panel().transcript.empty() && f.input.text == 0);
c.shutdown();
}
{
Fixture f; auto c = f.make(true); ready(c);
c.action(UiAction::BeginRecord); // held PTT, auto-focus armed
assert(c.state() == State::Recording && f.focus.destroyed == 0);
c.backend_changed(true); // selected row clicked again: restart, never retain held clip
assert(c.state() == State::Warming && !f.audio.opened && f.focus.destroyed == 1);
int submitted = f.worker.submissions;
c.action(UiAction::EndRecord); // old PTT release cannot submit after restart
c.tick(); assert(c.state() == State::Ready && f.worker.submissions == submitted);
c.action(UiAction::Insert); assert(f.input.text == 0);
release(c, f);
f.focus.valid = false; result(c, f, "review to discard");
assert(c.state() == State::Review && f.input.text == 0);
c.backend_changed(false, "Busy or missing selected model");
assert(c.state() == State::Error && !c.panel().record_available && c.panel().transcript.empty());
int starts = f.worker.starts;
c.action(UiAction::Cancel); // a stale Cancel cannot re-enable an unavailable model
c.tick(); c.action(UiAction::BeginRecord); c.action(UiAction::Enter);
assert(c.state() == State::Error && f.worker.starts == starts &&
f.input.enter == 0 && f.input.text == 0);
c.shutdown();
}
{
Fixture f; auto c = f.make(); ready(c);
release(c, f); result(c, f, "review to discard");
assert(c.state() == State::Review);
c.backend_changed(true); // current selected row: revoke review and restart
assert(c.state() == State::Warming && c.panel().transcript.empty());
c.action(UiAction::Insert); c.action(UiAction::EndRecord);
assert(f.input.text == 0 && f.worker.submissions == 1);
c.shutdown();
}
{
Fixture f; auto c = f.make(); ready(c);
assert(f.audio.prepares == 1 && f.audio.opened); // default idle drain
c.tick(); assert(f.audio.polls >= 2);
release(c, f);
c.action(UiAction::Enter); // Never submit while transcribing.
assert(f.input.enter == 0 && f.input.text == 0);
// Malformed controls and bad UTF-8 are request failures, not worker failures.
result(c, f, std::string("bad\x1b[31m"));
assert(c.state() == State::Error && f.worker.loaded && f.worker.starts == 1);
assert(c.panel().detail.find("transcription failed") != std::string::npos);
assert(c.panel().transcript.empty() && f.input.text == 0);
release(c, f);
result(c, f, std::string("bad\xc3\x28"));
assert(c.state() == State::Error && f.worker.loaded && f.worker.starts == 1);
release(c, f);
f.worker.replies.push_back({f.worker.request, {}, "transcription failed"});
c.tick();
assert(c.state() == State::Error && f.worker.loaded && f.worker.starts == 1);
release(c, f); result(c, f, "hello");
assert(c.state() == State::Review && c.panel().transcript == "hello");
assert(f.input.text == 0 && f.input.enter == 0);
c.action(UiAction::Insert);
assert(f.input.text == 1 && f.input.sent == "hello " && f.input.enter == 0);
c.action(UiAction::Insert); assert(f.input.text == 1); // at most once
release(c, f); result(c, f, std::string(4096, 'x'));
c.action(UiAction::Insert);
assert(f.input.sent.size() == 4096 && f.input.sent.back() == 'x');
assert(c.panel().detail.find("trailing space if it fits") != std::string::npos);
c.shutdown();
}
{
Fixture f; auto c = f.make(); ready(c);
c.action(UiAction::QuickChat);
assert(c.panel().quick_open && c.panel().quick_selected == 0);
c.action(UiAction::Record); assert(c.state() == State::Ready);
c.action(UiAction::QuickChat); assert(c.panel().quick_selected == 1);
c.action(UiAction::Cancel); assert(!c.panel().quick_open && f.input.text == 0);
c.action(UiAction::QuickChat); c.action(UiAction::Enter);
assert(f.input.text == 1 && f.input.sent == "Hi" && f.input.enter == 1);
assert(!c.panel().quick_open);
c.action(UiAction::Cancel); // close quick chat never dispatches a stale Enter
assert(f.input.enter == 1);
c.action(UiAction::QuickChat); f.input.unavailable = true;
c.action(UiAction::Enter);
assert(!c.panel().quick_open && f.input.enter == 1);
c.action(UiAction::Enter); // explicit new Enter alone, still unavailable
assert(f.input.enter == 1);
c.shutdown();
}
{
Fixture f; auto c = f.make(true); ready(c);
release(c, f); result(c, f, "auto");
assert(c.state() == State::Queued && f.input.sent == "auto " && f.input.enter == 0);
assert(c.panel().detail.find("never Enter") != std::string::npos);
c.tick(); assert(f.input.text == 1); // duplicate delivery prohibited
release(c, f);
f.focus.valid = false;
result(c, f, "review");
assert(c.state() == State::Review && f.input.text == 1);
c.action(UiAction::Insert);
assert(f.input.sent == "review " && f.input.text == 2 && f.input.enter == 0);
c.shutdown();
}
{
Fixture f; auto c = f.make(true); ready(c);
release(c, f);
f.input.lose_focus_on_acquire = true;
result(c, f, "focus changed during lease");
assert(c.state() == State::Review && f.input.text == 0);
f.input.lose_focus_on_acquire = false;
c.action(UiAction::Insert);
assert(f.input.text == 1 && f.input.enter == 0);
c.shutdown();
}
{
Fixture f; auto c = f.make(true); ready(c);
release(c, f);
f.input.fail_text = true;
result(c, f, "ambiguous");
assert(c.state() == State::Queued && f.input.text == 1 && f.input.enter == 0);
c.action(UiAction::Insert);
assert(f.input.text == 1); // uncertain delivery cannot be retried
c.shutdown();
}
{
Fixture f; auto c = f.make(true); ready(c);
release(c, f);
f.input.unavailable = true; result(c, f, "keep");
assert(c.state() == State::Review && f.input.text == 0);
f.input.unavailable = false;
c.action(UiAction::Insert);
assert(f.input.sent == "keep " && f.input.enter == 0);
c.settings(false, false, false);
release(c, f);
c.action(UiAction::Cancel); // cancel outstanding inference; do not deliver late replies
assert(c.state() == State::Error && !f.worker.loaded);
f.worker.replies.push_back({f.worker.request, "stale", {}});
c.tick(); assert(c.panel().transcript.empty() && f.input.text == 1);
c.action(UiAction::Record); c.tick(); assert(c.state() == State::Ready);
f.audio.samples = 3199;
c.action(UiAction::Record); c.action(UiAction::EndRecord);
assert(c.state() == State::Ready && f.worker.submissions == 2);
c.shutdown();
}
{
Fixture f; auto c = f.make(false); ready(c);
c.action(UiAction::Record);
c.settings(true, false, true); // neither setting retroactively arms focus
c.action(UiAction::EndRecord);
assert(!f.audio.opened);
result(c, f, "manual");
assert(c.state() == State::Review && f.input.text == 0 && f.input.enter == 0);
c.shutdown();
}
{
Fixture f; auto c = f.make(false, true); ready(c);
assert(!f.audio.opened && f.audio.prepares == 0);
release(c, f);
assert(f.audio.prepares == 1 && !f.audio.opened && f.audio.closes == 1);
result(c, f, "text"); assert(!f.audio.opened && c.state() == State::Review);
c.action(UiAction::Insert);
c.action(UiAction::Record); assert(f.audio.opened);
c.action(UiAction::Cancel); assert(!f.audio.opened);
c.settings(false, false, false); c.tick();
assert(f.audio.opened); // default resumes idle drain on next Ready tick
c.settings(false, true, true);
assert(f.worker.debug && f.worker.starts == 2 && !f.audio.opened);
c.tick(); assert(c.state() == State::Ready && !f.audio.opened);
c.shutdown();
}
// Native-style scheduler: authorization is captured at action start, not
// reacquired at the end of an asynchronous multi-commit task.
{
Fixture f;
auto now = std::chrono::steady_clock::time_point{};
PacedDelivery paced(f.acquire(), [&] { return now; });
auto c = f.make(false, false, &paced); ready(c);
release(c, f); result(c, f, std::string(81, 'x'));
c.action(UiAction::Enter);
assert(c.state() == State::Review && f.input.events.empty() && !c.panel().record_available);
c.action(UiAction::Record); c.action(UiAction::Insert);
assert(c.state() == State::Review && f.input.events.empty());
c.tick();
assert(c.state() == State::Queued && f.input.events.size() == 1 && f.input.events[0].size() == 24);
// Runtime reapplies settings every frame; unchanged values must not say
// delivery stopped while the remaining batches are still active.
c.settings(false, false, false);
assert(paced.active() && c.panel().status == "Input pacing - not a delivery receipt");
assert(c.panel().detail.find("dropped") == std::string::npos);
c.action(UiAction::Record); assert(c.state() == State::Queued);
for (int i = 0; i < 4; ++i) {
now += std::chrono::milliseconds(150); c.tick();
}
assert(f.input.events.size() == 5 && f.input.enter == 1);
std::string joined;
for (int i = 0; i < 4; ++i) joined += f.input.events[i];
assert(joined == std::string(81, 'x') + " " && f.input.events.back() == "<Enter>");
c.action(UiAction::Insert); assert(f.input.events.size() == 5);
// A separate Enter press is a new action but still obeys the gap.
c.action(UiAction::Enter); c.tick(); assert(f.input.enter == 1);
now += std::chrono::milliseconds(150); c.tick(); assert(f.input.enter == 2);
c.shutdown();
}
{
Fixture f;
auto now = std::chrono::steady_clock::time_point{};
PacedDelivery paced(f.acquire(), [&] { return now; });
auto c = f.make(false, false, &paced); ready(c);
release(c, f); result(c, f, "review preserved");
c.action(UiAction::Insert); f.focus.valid = false; c.tick();
assert(c.state() == State::Review && c.panel().transcript == "review preserved" && f.input.events.empty());
f.focus.valid = true; f.input.lose_focus_on_acquire = true;
c.action(UiAction::Insert);
assert(c.state() == State::Review && f.input.events.empty());
f.input.lose_focus_on_acquire = false; f.focus.valid = true;
c.action(UiAction::Insert); c.tick();
assert(c.state() == State::Queued && f.input.text == 1 && f.input.enter == 0);
c.shutdown();
}
{
Fixture f;
auto now = std::chrono::steady_clock::time_point{};
PacedDelivery paced(f.acquire(), [&] { return now; });
auto c = f.make(true, false, &paced); ready(c);
release(c, f); result(c, f, std::string(81, 'a'));
assert(c.state() == State::Queued && f.input.text == 1 && f.input.enter == 0);
f.focus.valid = false; now += std::chrono::milliseconds(150); c.tick();
assert(f.input.text == 1 && f.input.enter == 0 && !paced.active());
assert(c.panel().detail.find("Focus changed during pacing") != std::string::npos);
f.focus.valid = true;
c.action(UiAction::Insert); assert(f.input.text == 1); // auto cannot silently retarget
c.shutdown();
}
{
Fixture f;
auto now = std::chrono::steady_clock::time_point{};
PacedDelivery paced(f.acquire(), [&] { return now; });
auto c = f.make(true, false, &paced); ready(c);
f.input.lose_focus_on_acquire = true;
release(c, f); result(c, f, "auto review");
assert(c.state() == State::Review && c.panel().transcript == "auto review" && f.input.events.empty());
f.focus.valid = true; f.input.lose_focus_on_acquire = false;
c.action(UiAction::Insert); c.tick();
assert(c.state() == State::Queued && f.input.sent == "auto review ");
c.shutdown();
}
{
Fixture f;
auto now = std::chrono::steady_clock::time_point{};
PacedDelivery paced(f.acquire(), [&] { return now; });
auto c = f.make(false, false, &paced); ready(c);
release(c, f); result(c, f, std::string(81, 'a'));
c.action(UiAction::Enter); c.tick();
c.action(UiAction::Cancel); now += std::chrono::milliseconds(150); c.tick();
assert(f.input.text == 1 && f.input.enter == 0 && c.panel().transcript.empty());
release(c, f); result(c, f, std::string(81, 'b'));
c.action(UiAction::Enter); now += std::chrono::milliseconds(150); c.tick();
c.settings(false, true, false); now += std::chrono::milliseconds(150); c.tick();
assert(f.input.text == 2 && f.input.enter == 0 && c.state() == State::Ready);
release(c, f); result(c, f, std::string(81, 'c'));
c.action(UiAction::Enter);
c.backend_changed(true); // model restart drops a task before first send
now += std::chrono::milliseconds(150); c.tick();
assert(c.state() == State::Ready && f.input.text == 2 && f.input.enter == 0);
release(c, f); result(c, f, std::string(81, 'd'));
c.action(UiAction::Enter); c.action(UiAction::Quit);
now += std::chrono::milliseconds(150); c.tick();
assert(c.quitting() && f.input.text == 2 && f.input.enter == 0);
c.shutdown();
}
}
+5 -6
View File
@@ -146,14 +146,13 @@ void delivery_checks() {
}
{
auto s = review(std::string(4096, 'a')); FakeDelivery fake;
try { (void)deliver_enter(s, fake.factory()); CHECK(false); }
catch (const std::runtime_error&) {}
CHECK(s.state() == State::Review && s.text().size() == 4096);
CHECK(fake.acquisitions == 0 && fake.events.empty());
CHECK(deliver_enter(s, fake.factory()) == DeliveryResult::EnterQueued);
CHECK(fake.events == std::vector<std::string>({"text:" + std::string(4096, 'a'), "enter"}));
CHECK(s.state() == State::Queued && fake.acquisitions == 2);
auto with_existing_space = review(std::string(4095, 'a') + " ");
CHECK(deliver_insert(with_existing_space, fake.factory()) == DeliveryResult::TextQueued);
CHECK(fake.events[0] == "text:" + std::string(4095, 'a') + " ");
CHECK(with_existing_space.state() == State::Queued && fake.acquisitions == 1);
CHECK(fake.events[2] == "text:" + std::string(4095, 'a') + " ");
CHECK(with_existing_space.state() == State::Queued && fake.acquisitions == 3);
}
}
int main() {
+43
View File
@@ -0,0 +1,43 @@
if(NOT DEFINED APP OR NOT DEFINED ROOT OR NOT DEFINED STAGE)
message(FATAL_ERROR "APP, ROOT and STAGE required")
endif()
function(check app expected regex)
execute_process(COMMAND "${app}" ${ARGN}
RESULT_VARIABLE result OUTPUT_VARIABLE output ERROR_VARIABLE error TIMEOUT 15)
if(NOT "${result}" STREQUAL "${expected}" OR NOT output MATCHES "${regex}")
message(FATAL_ERROR "${ARGN}: exit=${result}, stdout=${output}, stderr=${error}")
endif()
endfunction()
check("${APP}" 0 "\"schema\": 1.*\"models\"|\"models\".*\"schema\": 1" --list-models --json)
check("${APP}" 1 "\"state\": \"not_installed\"" --check-model redux --model-dir "${STAGE}/absent" --json)
check("${APP}" 2 "^$" --check-model "redux;echo" --model-dir "${STAGE}/absent" --json)
check("${APP}" 2 "^$" --check-model "../redux" --model-dir "${STAGE}/absent" --json)
# Minimal installed payload, independent of the source tree's script/module/asset paths.
file(MAKE_DIRECTORY "${STAGE}/bin" "${STAGE}/scripts" "${STAGE}/python/frameyap" "${STAGE}/assets/backends")
file(COPY "${APP}" DESTINATION "${STAGE}/bin")
file(COPY "${ROOT}/scripts/model-status.py" DESTINATION "${STAGE}/scripts")
file(GLOB modules "${ROOT}/python/frameyap/*.py")
file(COPY ${modules} DESTINATION "${STAGE}/python/frameyap")
file(COPY "${ROOT}/assets/backends/redux.json" DESTINATION "${STAGE}/assets/backends")
get_filename_component(name "${APP}" NAME)
check("${STAGE}/bin/${name}" 0 "redux.*unknown" --list-models)
check("${STAGE}/bin/${name}" 1 "\"reason\": \"directory_missing\"" --check-model redux --model-dir "${STAGE}/absent" --json)
# Tiny local pinned fixture exercises real hash verification through the CLI.
file(MAKE_DIRECTORY "${STAGE}/fixtures" "${STAGE}/models/toy")
file(WRITE "${STAGE}/models/toy/weights.bin" "hello")
file(SHA256 "${STAGE}/models/toy/weights.bin" pinned_hash)
file(WRITE "${STAGE}/fixtures/toy.json" "{\n"
"\"schema\":1,\"id\":\"toy\",\"display_name\":\"Tiny fixture\",\n"
"\"launcher\":{\"type\":\"python\",\"path\":\"python/frameyap/worker.py\","
"\"arguments\":[\"{model_dir}\",\"{clip_dir}\"],\"protocol\":\"frameyap-worker-v1\"},\n"
"\"model\":{\"source\":\"https://example.invalid/toy\",\"revision\":\"fixture\","
"\"files\":[{\"path\":\"weights.bin\",\"size\":5,\"sha256\":\"${pinned_hash}\"}]},\n"
"\"attribution\":\"fixture only\",\"license\":{\"id\":\"MIT\",\"text\":\"fixture only\"},"
"\"requirements\":{\"cpu\":\"fixture only\",\"gpu\":\"none\"}}\n")
check("${STAGE}/bin/${name}" 0 "\"state\": \"installed_verified\""
--check-model toy --manifest-dir "${STAGE}/fixtures" --model-dir "${STAGE}/models/toy" --json)
check("${STAGE}/bin/${name}" 0 "\"state\": \"installed_verified\""
--list-models --manifest-dir "${STAGE}/fixtures" --model-dir "${STAGE}/models" --json)
file(WRITE "${STAGE}/models/toy/weights.bin" "HELLO")
check("${STAGE}/bin/${name}" 1 "\"reason\": \"hash_mismatch\""
--check-model toy --manifest-dir "${STAGE}/fixtures" --model-dir "${STAGE}/models/toy" --json)
+168
View File
@@ -0,0 +1,168 @@
#include "paced_delivery.hpp"
#include <cassert>
#include <chrono>
#include <stdexcept>
#include <string>
#include <vector>
using namespace frameyap;
using namespace std::chrono_literals;
namespace {
struct Fake {
using Time = std::chrono::steady_clock::time_point;
Time now{};
std::vector<std::string> events;
std::vector<Time> times;
bool focus = true, fail_acquire = false, fail_text = false, fail_enter = false;
bool lose_on_acquire = false;
int active = 0, acquisitions = 0, releases = 0;
struct Lease : DeliveryLease {
Fake& f;
explicit Lease(Fake& value) : f(value) { assert(f.active++ == 0); }
~Lease() override { --f.active; }
void text(const std::string& text) override {
f.events.push_back(text); f.times.push_back(f.now);
if (f.fail_text) throw std::runtime_error("uncertain text");
}
void enter() override {
f.events.push_back("<Enter>"); f.times.push_back(f.now);
if (f.fail_enter) throw std::runtime_error("uncertain Enter");
}
};
DeliveryFactory acquire() {
return [this]() -> std::unique_ptr<DeliveryLease> {
++acquisitions;
if (fail_acquire) throw std::runtime_error("unavailable");
auto lease = std::make_unique<Lease>(*this);
if (lose_on_acquire) focus = false;
return lease;
};
}
PacedDelivery make() {
return PacedDelivery(acquire(), [this] { return now; }, [this] { ++releases; });
}
};
void drain(Fake& f, PacedDelivery& p) {
for (int n = 0; p.active() && n < 300; ++n) {
p.tick(); f.now += 150ms;
}
assert(!p.active());
}
void bounds_and_gap() {
Fake f; auto p = f.make();
const std::string ascii(4096, 'a');
int begun = 0;
p.start(ascii, true, [&] { return f.focus; }, [&] { ++begun; });
assert(f.events.empty() && begun == 0); // start never commits
auto outcome = p.tick();
assert(!outcome && begun == 1 && f.events == std::vector<std::string>{std::string(24, 'a')});
f.now += 149ms; p.tick(); assert(f.events.size() == 1);
f.now += 1ms; drain(f, p);
assert(f.events.size() == 172); // ceil(4096/24) text commits plus Enter
assert(f.events.back() == "<Enter>");
std::string joined;
for (size_t i = 0; i + 1 < f.events.size(); ++i) {
assert(f.events[i].size() <= 24);
joined += f.events[i];
}
assert(joined == ascii && begun == 1);
// Another explicit action must respect the previous task's final commit.
f.now -= 100ms;
p.start("next", false, [&] { return f.focus; }, [] {});
p.tick(); assert(f.events.size() == 172);
f.now += 99ms; p.tick(); assert(f.events.size() == 172);
f.now += 1ms; p.tick(); assert(f.events.back() == "next");
for (size_t i = 1; i < f.times.size(); ++i) assert(f.times[i] - f.times[i-1] >= 150ms);
assert(f.active == 0);
f.now += 150ms; p.tick(); assert(f.releases == 1);
p.tick(); assert(f.releases == 1);
// 81 repeated ASCII codepoints exceed the keycode rotating-table window:
// four 24-point messages, never a single oversized commit.
p.start(std::string(81, 'x'), false, [&] { return f.focus; }, [] {});
auto before = f.events.size(); drain(f, p);
assert(f.events.size() == before + 4);
assert(f.events[before].size() == 24 && f.events.back().size() == 9);
}
void unicode_and_partial() {
Fake f; auto p = f.make();
const std::string mixed = "界😀éa";
std::string payload;
for (int n = 0; n < 30; ++n) payload += mixed;
p.start(payload, true, [&] { return f.focus; }, [] {});
drain(f, p);
std::string joined;
for (const auto& batch : f.events) if (batch != "<Enter>") {
assert(literal_text(batch) == batch);
joined += batch;
}
assert(joined == payload && f.events.size() == 6 && f.events.back() == "<Enter>");
f.now += 150ms;
p.start("lost before", true, [&] { return f.focus; }, [] {});
f.focus = false;
auto before = f.events.size();
auto blocked = p.tick();
assert(blocked && !blocked->began && blocked->focus_lost && f.events.size() == before);
f.focus = true;
p.start(std::string(80, 'z'), true, [&] { return f.focus; }, [] {});
p.tick(); assert(f.events.size() == before + 1);
f.focus = false; f.now += 150ms;
auto partial = p.tick();
assert(partial && partial->began && partial->focus_lost && f.events.size() == before + 1);
assert(f.events.back() != "<Enter>");
f.focus = true; f.now += 150ms;
p.start("text then maybe Enter", true, [&] { return f.focus; }, [] {});
p.tick();
f.focus = false; f.now += 150ms;
partial = p.tick();
assert(partial && partial->began && partial->focus_lost);
assert(f.events.back() == "text then maybe Enter" && !p.active());
}
void permit_revocation() {
Fake f; bool permitted = true;
PacedDelivery p(f.acquire(), [&] { return f.now; }, {}, [&] { return permitted; });
int consumed = 0;
p.start("review", true, [&] { return f.focus; }, [&] { ++consumed; });
permitted = false;
auto stopped = p.tick();
assert(stopped && !stopped->began && stopped->focus_lost && consumed == 0 && f.events.empty());
permitted = true;
p.start(std::string(81, 'p'), true, [&] { return f.focus; }, [&] { ++consumed; });
p.tick(); assert(consumed == 1 && f.events.size() == 1);
permitted = false; f.now += 150ms;
stopped = p.tick();
assert(stopped && stopped->began && f.events.size() == 1 && !p.active());
}
void refusal_cancel_uncertain() {
Fake f; auto p = f.make();
int consumed = 0;
f.lose_on_acquire = true;
try { p.start("review", false, [&] { return f.focus; }, [&] { ++consumed; }); assert(false); }
catch (const std::runtime_error&) {}
assert(!p.active() && consumed == 0 && f.events.empty() && f.active == 0);
f.lose_on_acquire = false; f.focus = true; f.fail_acquire = true;
try { p.start("review", true, [&] { return f.focus; }, [&] { ++consumed; }); assert(false); }
catch (const std::runtime_error&) {}
assert(!p.active() && consumed == 0 && f.events.empty());
f.fail_acquire = false;
p.start("cancelled before first", true, [&] { return f.focus; }, [&] { ++consumed; });
p.cancel(); p.tick(); assert(consumed == 0 && f.events.empty() && f.active == 0);
p.start(std::string(100, 'a'), true, [&] { return f.focus; }, [&] { ++consumed; });
p.tick(); assert(consumed == 1 && f.events.size() == 1);
p.cancel(); f.now += 300ms; p.tick(); assert(f.events.size() == 1 && f.releases == 2);
f.fail_text = true;
p.start("uncertain", true, [&] { return f.focus; }, [&] { ++consumed; });
auto out = p.tick();
assert(out && out->began && out->result == DeliveryResult::TextUncertain);
assert(f.events.size() == 2 && consumed == 2);
f.fail_text = false; f.now += 150ms;
p.start("ok", true, [&] { return f.focus; }, [] {});
p.tick(); f.now += 150ms; f.fail_enter = true;
out = p.tick(); assert(out && out->result == DeliveryResult::EnterUncertain);
assert(f.events.back() == "<Enter>");
f.now += 150ms; f.fail_enter = false;
p.start("body", true, [&] { return f.focus; }, [] {});
p.tick(); f.now += 150ms; f.fail_acquire = true;
out = p.tick(); assert(out && out->result == DeliveryResult::TextQueuedEnterUnavailable);
assert(f.events.back() == "body" && f.active == 0);
}
} // namespace
int main() { bounds_and_gap(); unicode_and_partial(); permit_revocation(); refusal_cancel_uncertain(); }
+153 -2
View File
@@ -1,4 +1,5 @@
#include "core.hpp"
#include "paced_delivery.hpp"
#include "text_input.hpp"
#include "gamescope-input-method-server.h"
#include <wayland-server.h>
@@ -69,6 +70,7 @@ struct Snapshot {
int creates = 0, destroyed = 0;
bool bad_seat = false;
std::vector<Event> events;
std::vector<std::string> pending, delivered;
};
class FakeServer {
public:
@@ -109,6 +111,13 @@ public:
std::unique_lock lock(mutex_);
return changed_.wait_for(lock, 1s, [&] { return state_.destroyed >= count; });
}
// Model asynchronous target delivery separately from IME request processing.
// A Wayland sync can see commit before the destination drains keys.
void drain_target() {
std::lock_guard lock(mutex_);
state_.delivered.insert(state_.delivered.end(), state_.pending.begin(), state_.pending.end());
state_.pending.clear();
}
static constexpr const char* socket = "frameyap-fake";
private:
struct DisplayDeleter { void operator()(wl_display* p) const { if (p) wl_display_destroy(p); } };
@@ -159,6 +168,7 @@ private:
{
std::lock_guard lock(server.mutex_);
++server.state_.destroyed;
server.state_.pending.clear(); // A teardown may invalidate undrained keys/keymap.
}
server.changed_.notify_all();
}
@@ -166,6 +176,11 @@ private:
auto& server = owner(r);
std::lock_guard lock(server.mutex_);
server.state_.events.push_back({"commit", "", serial});
if (server.state_.events.size() >= 2) {
const auto& previous = server.state_.events[server.state_.events.size() - 2];
if (previous.kind == "string") server.state_.pending.push_back(previous.text);
if (previous.kind == "action") server.state_.pending.push_back("<Enter>");
}
}
static void set_string(wl_client*, wl_resource* r, const char* text) {
auto& server = owner(r);
@@ -185,16 +200,20 @@ void test_text() {
TextInput input(server.socket);
auto initial = server.snapshot();
CHECK(initial.creates == 1 && !initial.bad_seat && initial.events.empty());
input.text("Hello 世界 😀\r\nnext\tline\xe2\x80\xa8 end");
input.text("Hello 世界 😀\r\nnext");
auto state = server.snapshot();
CHECK(state.events.size() == 2);
CHECK(state.events[0].kind == "string");
CHECK(state.events[0].text == "Hello 世界 😀 next line end");
CHECK(state.events[0].text == "Hello 世界 😀 next");
CHECK(state.events[1].kind == "commit" && state.events[1].number == 42);
// Authorization is consumed even when a duplicate delivery is attempted.
throws([&] { input.enter(); });
CHECK(server.snapshot().events.size() == 2);
}
CHECK(server.snapshot().destroyed == 0); // sync != target delivery
server.drain_target();
CHECK((server.snapshot().delivered == std::vector<std::string>{"Hello 世界 😀 next"}));
TextInput::release_idle();
CHECK(server.wait_destroyed(1));
CHECK(server.snapshot().events.size() == 2);
}
@@ -213,6 +232,10 @@ void test_enter() {
throws([&] { input.text("duplicate"); });
CHECK(server.snapshot().events.size() == 2);
}
CHECK(server.snapshot().destroyed == 0);
server.drain_target();
CHECK((server.snapshot().delivered == std::vector<std::string>{"<Enter>"}));
TextInput::release_idle();
CHECK(server.wait_destroyed(1));
CHECK(server.snapshot().events.size() == 2);
}
@@ -229,6 +252,7 @@ void test_invalid() {
throws([&] { input.text("\xe2\x82"); });
throws([&] { input.text("\xc2\x85"); });
throws([&] { input.text(std::string(4097, 'a')); });
throws([&] { input.text(std::string(25, 'a')); }); // server keymap, not Wayland bytes
throws([&] { input.text("\t\n"); });
CHECK(server.snapshot().events.empty());
input.text("submit; $(echo x)");
@@ -236,6 +260,7 @@ void test_invalid() {
CHECK(events.size() == 2 && events[0].text == "submit; $(echo x)");
CHECK(events[1].kind == "commit");
}
TextInput::release_idle();
CHECK(server.wait_destroyed(1));
}
@@ -259,9 +284,133 @@ void test_review_insert() {
CHECK(events[0].text == "send 世界");
CHECK(events[1].kind == "commit" && events[1].number == 42);
}
TextInput::release_idle();
CHECK(server.wait_destroyed(1));
}
// A fake target queue intentionally decouples compositor commit from target
// delivery. This regression catches an IME close/recreate between successive
// sends; it does not claim that a real application's input was acknowledged.
void test_repeated_full_payloads() {
FakeServer server;
struct Lease : DeliveryLease {
explicit Lease(const char* socket) : input(socket) {}
void text(const std::string& s) override { input.text(s); }
void enter() override { input.enter(); }
TextInput input;
};
auto now = std::chrono::steady_clock::time_point{};
PacedDelivery paced([&]() -> std::unique_ptr<DeliveryLease> {
return std::make_unique<Lease>(server.socket);
}, [&] { return now; });
auto send = [&](std::string payload, bool enter) {
paced.start(std::move(payload), enter, [] { return true; }, [] {});
int commits = 0;
while (paced.active()) {
auto before = server.snapshot().pending.size();
paced.tick();
auto after = server.snapshot().pending.size();
CHECK(after == before || after == before + 1);
if (after != before) {
++commits;
now += 150ms;
} else now += 150ms;
}
return commits;
};
CHECK(send("first submission ", true) == 2);
CHECK(server.snapshot().creates == 1 && server.snapshot().destroyed == 0);
throws([&] { TextInput wrong("another-socket"); });
// Reproduce six retained-IME, 81-ASCII actions as protocol requests.
// This fake cannot emulate Gamescope's rotating keycode implementation.
for (int repeat = 0; repeat < 6; ++repeat)
CHECK(send(std::string(81, 'r'), false) == 4);
CHECK(server.snapshot().creates == 1 && server.snapshot().destroyed == 0);
auto repeated = server.snapshot().pending;
CHECK(repeated.size() == 26);
for (size_t i = 2; i < repeated.size(); i += 4)
CHECK(repeated[i] + repeated[i+1] + repeated[i+2] + repeated[i+3] == std::string(81, 'r'));
// A long UTF-8 literal stays intact while each protocol message uses a
// bounded number of codepoints, not the previous 4080-byte chunk size.
std::string bounded = "A";
for (int i = 0; i < 1363; ++i) bounded += "界";
bounded += "end!!!";
CHECK(bounded.size() == 4096);
auto count = send(bounded, false);
CHECK(count == 58); // ceil(1370 / 24)
auto pending = server.snapshot().pending;
CHECK(pending.size() == 26 + static_cast<size_t>(count));
CHECK(pending[0] == "first submission " && pending[1] == "<Enter>");
std::string joined;
for (size_t i = 26; i < pending.size(); ++i) {
CHECK(literal_text(pending[i]) == pending[i]);
size_t points = 0;
for (unsigned char ch : pending[i]) if ((ch & 0xc0) != 0x80) ++points;
CHECK(points <= 24);
joined += pending[i];
}
CHECK(joined == bounded);
server.drain_target();
CHECK(server.snapshot().delivered == pending);
TextInput::release_idle();
CHECK(server.wait_destroyed(1));
CHECK(server.snapshot().pending.empty());
}
void test_space_batches() {
FakeServer server;
struct Lease : DeliveryLease {
explicit Lease(const char* socket) : input(socket) {}
void text(const std::string& s) override { input.text(s); }
void enter() override { input.enter(); }
TextInput input;
};
auto now = std::chrono::steady_clock::time_point{};
PacedDelivery paced([&]() -> std::unique_ptr<DeliveryLease> {
return std::make_unique<Lease>(server.socket);
}, [&] { return now; });
for (const auto& literal : {std::string(24, ' ') + "tail", std::string(24, 'X') + std::string(24, ' ')}) {
const auto first = server.snapshot().pending.size();
paced.start(literal, false, [] { return true; }, [] {});
std::optional<PacedDelivery::Outcome> outcome;
while (paced.active()) { outcome = paced.tick(); now += 150ms; }
CHECK(outcome && outcome->result == DeliveryResult::TextQueued);
const auto pending = server.snapshot().pending;
std::string joined;
for (size_t i = first; i < pending.size(); ++i) joined += pending[i];
CHECK(joined == literal);
}
CHECK(literal_text(std::string(24, ' ')).empty()); // whole-ASR policy unchanged
server.drain_target();
TextInput::release_idle();
CHECK(server.wait_destroyed(1));
}
void test_discovery_does_not_hold_ime() {
FakeServer server;
{
TextInput input(server.socket);
CHECK(server.snapshot().creates == 1 && server.snapshot().events.empty());
}
CHECK(server.wait_destroyed(1)); // no input sent: allow other seat IMEs
TextInput::release_idle();
}
void test_release_not_ack() {
FakeServer server;
const auto before = std::chrono::steady_clock::now();
{
TextInput input(server.socket);
throws([&] { TextInput::release_idle(); }); // active lease is exclusive
input.text("undrained");
}
CHECK(server.snapshot().pending == std::vector<std::string>({"undrained"}));
TextInput::release_idle(); // immediate Quit still honors the remaining cooldown
CHECK(std::chrono::steady_clock::now() - before >= 100ms);
CHECK(server.wait_destroyed(1));
CHECK(server.snapshot().delivered.empty()); // fixture models possible loss
}
void test_unavailable() {
FakeServer server(FakeServer::Mode::Unavailable);
throws([&] { TextInput input(server.socket); });
@@ -282,6 +431,8 @@ void test_stalled() {
int main() {
try {
test_text(); test_enter(); test_invalid(); test_review_insert();
test_repeated_full_payloads(); test_space_batches();
test_discovery_does_not_hold_ime(); test_release_not_ack();
test_unavailable(); test_stalled();
std::cout << "native fake Wayland input checks passed\n";
} catch (const std::exception& e) {