Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,17 @@ jobs:
- name: Check Tauri backend (cargo check)
run: cargo check --manifest-path src-tauri/Cargo.toml

- name: Run Rust backend unit tests
if: runner.os != 'Windows'
run: cargo test --manifest-path src-tauri/Cargo.toml --lib

- name: Compile Rust backend unit tests (Windows)
# Windows runner 能链接 lib test binary,但干净镜像缺少可选 native runtime
# DLL entrypoint 时,进程会在 test harness 启动前退出。这里保留 cfg/link
# 覆盖;共享单测在 macOS / Linux 上实际执行。
if: runner.os == 'Windows'
run: cargo test --manifest-path src-tauri/Cargo.toml --lib --no-run

- name: Verify version sync across all 5 files
# 两个平台都跑这个校验:Windows runner 自带 git-bash,跨 shell 表现一致。
# 一旦版本号 drift 立刻 fail,避免发版时再发现漏改。
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Generated artifacts:

Logs: `~/Library/Logs/OpenLess/openless.log` (macOS) / `%LOCALAPPDATA%\OpenLess\Logs\openless.log` (Windows).

There is no test runner wired in for the frontend. `src/lib/providerSetup.test.ts` is a hand-rolled assertion script — run with `npx tsx src/lib/providerSetup.test.ts` if you need it. Rust side has no `cargo test` targets yet; behavior is verified by running the app.
There is no test runner wired in for the frontend. `src/lib/providerSetup.test.ts` is a hand-rolled assertion script — run with `npx tsx src/lib/providerSetup.test.ts` if you need it. Rust backend unit tests are run with `cargo test --manifest-path src-tauri/Cargo.toml --lib`; hardware / OS-integration behavior is still verified by running the app.

## Architecture

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Generated artifacts:

Logs: `~/Library/Logs/OpenLess/openless.log` (macOS) / `%LOCALAPPDATA%\OpenLess\Logs\openless.log` (Windows).

There is no test runner wired in for the frontend. `src/lib/providerSetup.test.ts` is a hand-rolled assertion script — run with `npx tsx src/lib/providerSetup.test.ts` if you need it. Rust side has no `cargo test` targets yet; behavior is verified by running the app.
There is no test runner wired in for the frontend. `src/lib/providerSetup.test.ts` is a hand-rolled assertion script — run with `npx tsx src/lib/providerSetup.test.ts` if you need it. Rust backend unit tests are run with `cargo test --manifest-path src-tauri/Cargo.toml --lib`; hardware / OS-integration behavior is still verified by running the app.

## Architecture

Expand Down
162 changes: 162 additions & 0 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4047,6 +4047,9 @@ fn resolve_ark_endpoint_with_policy(
mod tests {
use super::*;
use crate::types::HotkeyTrigger;
use once_cell::sync::Lazy;

static ENV_LOCK: Lazy<tokio::sync::Mutex<()>> = Lazy::new(|| tokio::sync::Mutex::new(()));

fn session_id(n: u128) -> SessionId {
Uuid::from_u128(n)
Expand All @@ -4058,6 +4061,7 @@ mod tests {
.filter_level(log::LevelFilter::Info)
.is_test(false)
.try_init();
let _guard = ENV_LOCK.lock().await;
std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1");

let coordinator = Coordinator::new();
Expand All @@ -4067,6 +4071,52 @@ mod tests {
std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN");
}

#[tokio::test]
async fn begin_session_dry_run_enters_listening_and_clears_stale_edges() {
let _guard = ENV_LOCK.lock().await;
std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1");

let coordinator = Coordinator::new();
let old_session_id = coordinator.inner.state.lock().session_id;
{
let mut state = coordinator.inner.state.lock();
state.pending_stop = true;
state.cancelled = true;
}

coordinator.start_dictation().await.unwrap();

let state = coordinator.inner.state.lock();
assert_eq!(state.phase, SessionPhase::Listening);
assert!(!state.pending_stop);
assert!(!state.cancelled);
assert_ne!(state.session_id, old_session_id);

std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN");
}

#[tokio::test]
async fn begin_session_ignores_non_idle_phase() {
let _guard = ENV_LOCK.lock().await;
std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1");

let coordinator = Coordinator::new();
let old_session_id = {
let mut state = coordinator.inner.state.lock();
state.phase = SessionPhase::Processing;
state.session_id = session_id(99);
state.session_id
};

coordinator.start_dictation().await.unwrap();

let state = coordinator.inner.state.lock();
assert_eq!(state.phase, SessionPhase::Processing);
assert_eq!(state.session_id, old_session_id);

std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN");
}

#[test]
fn window_key_matcher_mirrors_windows_trigger_aliases() {
let cases = [
Expand Down Expand Up @@ -4245,6 +4295,50 @@ mod tests {
assert!(state.pending_stop);
}

#[tokio::test]
async fn stop_dictation_from_listening_without_asr_returns_idle() {
let coordinator = Coordinator::new();
{
let mut state = coordinator.inner.state.lock();
state.phase = SessionPhase::Listening;
state.session_id = session_id(123);
}

coordinator.stop_dictation().await.unwrap();

assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle);
}

#[test]
fn cancel_session_state_machine_is_table_driven() {
let cases = [
(SessionPhase::Idle, SessionPhase::Idle, false),
(SessionPhase::Starting, SessionPhase::Idle, true),
(SessionPhase::Listening, SessionPhase::Idle, true),
(SessionPhase::Processing, SessionPhase::Processing, true),
(SessionPhase::Inserting, SessionPhase::Inserting, false),
];

for (initial, expected_phase, expected_cancelled) in cases {
let coordinator = Coordinator::new();
{
let mut state = coordinator.inner.state.lock();
state.phase = initial;
state.cancelled = false;
state.focus_target = Some(1);
}

coordinator.cancel_dictation();

let state = coordinator.inner.state.lock();
assert_eq!(state.phase, expected_phase, "initial={initial:?}");
assert_eq!(state.cancelled, expected_cancelled, "initial={initial:?}");
if matches!(initial, SessionPhase::Starting | SessionPhase::Listening) {
assert!(state.focus_target.is_none(), "initial={initial:?}");
}
}
}

#[test]
fn recorder_runtime_error_aborts_active_session() {
let coordinator = Coordinator::new();
Expand Down Expand Up @@ -4483,6 +4577,74 @@ mod tests {
);
}

#[test]
fn startup_race_check_is_table_driven_for_begin_session_edges() {
let cases = [
(
SessionPhase::Starting,
false,
session_id(7),
StartupRaceStatus::ActiveStarting,
),
(
SessionPhase::Starting,
true,
session_id(7),
StartupRaceStatus::CancelRaced,
),
(
SessionPhase::Idle,
false,
session_id(7),
StartupRaceStatus::CancelRaced,
),
(
SessionPhase::Listening,
false,
session_id(7),
StartupRaceStatus::CancelRaced,
),
(
SessionPhase::Starting,
false,
session_id(8),
StartupRaceStatus::StaleContinuation,
),
];

for (phase, cancelled, actual_session_id, expected) in cases {
let mut state = SessionState::default();
state.phase = phase;
state.cancelled = cancelled;
state.session_id = actual_session_id;

assert_eq!(
startup_race_status(&state, session_id(7)),
expected,
"phase={phase:?} cancelled={cancelled} actual_session={actual_session_id}"
);
}
}

#[test]
fn begin_recording_abort_is_noop_after_prior_cancel_or_idle() {
let cases = [
(SessionPhase::Idle, false),
(SessionPhase::Processing, false),
(SessionPhase::Listening, true),
];

for (phase, cancelled) in cases {
let mut state = SessionState::default();
state.phase = phase;
state.cancelled = cancelled;

assert!(begin_recording_abort_before_restore(&mut state).is_none());
assert_eq!(state.phase, phase);
assert_eq!(state.cancelled, cancelled);
}
}

#[test]
fn stale_startup_cleanup_keeps_newer_asr_resource() {
let coordinator = Coordinator::new();
Expand Down
Loading
Loading