mirror of
https://github.com/baketnk/frame-yap.git
synced 2026-10-06 03:00:04 +02:00
feat(runtime): integrate backend controls and guarded paced text delivery
This commit is contained in:
1 parent
1321ca8316
commit
9829d0611d
26 files changed
+2238
-399
No files matched your search
+38
-4
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"}));
|
||||
}
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user