diff --git a/.github/workflows/oxytype-ci.yml b/.github/workflows/oxytype-ci.yml index f21911ca78..75ee28b8fd 100644 --- a/.github/workflows/oxytype-ci.yml +++ b/.github/workflows/oxytype-ci.yml @@ -73,7 +73,7 @@ jobs: - 'packages/oxlint-config/**' # Workspace packages bundled into the backend and frontend. runtime-packages: - - 'packages/{challenges,contracts,funbox,schemas,typing-core,typing-engine,typing-fixtures,util}/**' + - 'packages/{challenges,contracts,funbox,schemas,typing-engine,typing-fixtures,util,web-shared}/**' - 'crates/**' - 'Cargo.{toml,lock}' - 'rust-toolchain.toml' @@ -95,8 +95,8 @@ jobs: - '!**/*.md' backend-tests: - 'backend/__tests__/**' - # Anticheat specs replay typing-core's recorded fixtures. - - 'packages/typing-core/__fixtures__/**' + # Anticheat specs replay the immutable engine reference fixtures. + - 'packages/typing-fixtures/reference/**' # Backend test data and D1 helpers the frontend unit and SSR suites import. backend-test-support: - 'backend/__tests__/{__testData__/**,d1/helpers.ts,d1/result-support.ts}' @@ -109,8 +109,8 @@ jobs: frontend-tests: - 'frontend/__tests__/**' - 'frontend/vitest.config.ts' - # Parity specs replay typing-core's recorded fixtures. - - 'packages/typing-core/__fixtures__/**' + # Parity specs replay the immutable engine reference fixtures. + - 'packages/typing-fixtures/reference/**' - '!frontend/__tests__/start/**' # Start harness: SSR suites, and the workerd fixture the browser suites serve. start-tests: diff --git a/README.md b/README.md index 2100484790..f7bb79d4df 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,15 @@ vp run dev The TanStack Start app, its API and local D1 run at `http://localhost:3000`. +Typing rules run in the platform-independent [Rust engine](./crates/typing-engine). +The website loads its [Wasm SDK](./packages/typing-engine/README.md) only in the +browser; Cloudflare continues serving SSR, auth and APIs. Install the toolchain +pinned in `rust-toolchain.toml` with its Wasm target. Build bootstrap generates the +bindings automatically; `vp run engine:parity` checks native and built-browser +behavior against immutable original references. `vp run engine:lint` and +`vp run engine:fmt` check Rust. Reference provenance and measured budgets are in +[migration progress](./docs/rust-engine/progress.md). + ## Contributing See [CONTRIBUTING.md](./docs/CONTRIBUTING.md) and the [code of conduct](./docs/CODE_OF_CONDUCT.md). Report bugs in the [Oxytype repository](https://github.com/voltcrash/oxytype/issues). diff --git a/backend/__tests__/anticheat/detectors.spec.ts b/backend/__tests__/anticheat/detectors.spec.ts index 884b0c2811..0c2bd95e50 100644 --- a/backend/__tests__/anticheat/detectors.spec.ts +++ b/backend/__tests__/anticheat/detectors.spec.ts @@ -14,7 +14,7 @@ import { const fixtures = resolve( __dirname, - "../../../packages/typing-core/__fixtures__", + "../../../packages/typing-fixtures/reference", ); function load(path: string): TimingSample[] { diff --git a/backend/scripts/anticheat-calibrate.ts b/backend/scripts/anticheat-calibrate.ts index b49e6b591e..8cfee7d0b6 100644 --- a/backend/scripts/anticheat-calibrate.ts +++ b/backend/scripts/anticheat-calibrate.ts @@ -22,9 +22,9 @@ recordings and synthetic fixtures are used.`; const root = resolve(__dirname, "../.."); const DEFAULT_SETS: [string, string][] = [ // scripted Playwright input against the real web client, not human typing - ["recorded-web", "packages/typing-core/__fixtures__/keystrokes"], + ["recorded-web", "packages/typing-fixtures/reference/keystrokes"], // label comes from each file: bot or model - ["", "packages/typing-core/__fixtures__/anticheat"], + ["", "packages/typing-fixtures/reference/anticheat"], ]; async function files(path: string): Promise { diff --git a/crates/typing-engine-wasm/src/lib.rs b/crates/typing-engine-wasm/src/lib.rs index 00678e09a0..b6adf6f8db 100644 --- a/crates/typing-engine-wasm/src/lib.rs +++ b/crates/typing-engine-wasm/src/lib.rs @@ -91,6 +91,28 @@ impl WasmSession { .ok_or_else(|| failure(ErrorCode::Disposed, "Session disposed"))?; serialize(&session.live_stats(now).map_err(bind_error)?) } + pub fn composition_completes(&self, data: &str) -> Result { + let data = parse(data)?; + let session = self + .session + .as_ref() + .ok_or_else(|| failure(ErrorCode::Disposed, "Session disposed"))?; + session.composition_completes(&data).map_err(bind_error) + } + pub fn can_delete(&self) -> Result { + let session = self + .session + .as_ref() + .ok_or_else(|| failure(ErrorCode::Disposed, "Session disposed"))?; + Ok(session.can_delete()) + } + pub fn word_burst(&self, index: usize, now: f64) -> Result { + let session = self + .session + .as_ref() + .ok_or_else(|| failure(ErrorCode::Disposed, "Session disposed"))?; + session.word_burst(index, now).map_err(bind_error) + } pub fn snapshot(&self) -> Result { let session = self .session @@ -148,6 +170,22 @@ impl WasmContent { .prepare_repeating(input, repeating) .map_err(bind_error) } + // Reuse the existing prepared-asset envelope/parser. Only quotes, random and + // quote lengths participate; other preparation fields do not affect catalogs. + pub fn quote_catalog(&mut self, input: &str, refresh_lengths: bool) -> Result { + let input: typing_engine::generation::Prepare = parse(input)?; + let asset = input + .quotes + .ok_or_else(|| failure(ErrorCode::InvalidContent, "Quote catalog asset missing"))?; + let lengths = refresh_lengths.then_some(input.config.quote_length); + let collection = self + .content + .as_mut() + .ok_or_else(|| failure(ErrorCode::Disposed, "Content disposed"))? + .quote_catalog(asset, lengths, input.random) + .map_err(bind_error)?; + serialize(&collection) + } pub fn update_quote_queue(&mut self, lengths: &str, random: &str) -> Result { let lengths: Vec = parse(lengths)?; let random = parse(random)?; @@ -170,13 +208,12 @@ impl WasmContent { .map_err(bind_error) } pub fn generate(&mut self, repeating: bool) -> Result { - let generator = self + let generated = self .content .as_mut() .ok_or_else(|| failure(ErrorCode::Disposed, "Content disposed"))? - .generator() + .generate(repeating) .map_err(bind_error)?; - let generated = generator.generate(repeating).map_err(bind_error)?; serialize(&generated) } pub fn next( @@ -202,9 +239,9 @@ impl WasmContent { pub fn metadata(&mut self) -> Result { let generator = self .content - .as_mut() + .as_ref() .ok_or_else(|| failure(ErrorCode::Disposed, "Content disposed"))? - .generator() + .prepared_generator() .map_err(bind_error)?; serialize( &serde_json::json!({ "allWordsGenerated": generator.all_generated(), "currentQuote": generator.current_quote }), @@ -213,9 +250,9 @@ impl WasmContent { pub fn request(&mut self) -> Result { let generator = self .content - .as_mut() + .as_ref() .ok_or_else(|| failure(ErrorCode::Disposed, "Content disposed"))? - .generator() + .prepared_generator() .map_err(bind_error)?; serialize(&generator.pending_request()) } diff --git a/crates/typing-engine/src/analysis.rs b/crates/typing-engine/src/analysis.rs index 4e57c98dda..5f0575a2d7 100644 --- a/crates/typing-engine/src/analysis.rs +++ b/crates/typing-engine/src/analysis.rs @@ -22,6 +22,7 @@ pub enum Query { Frame { time: f64, }, + Inspect, CustomProgress { previous: u32, }, @@ -53,6 +54,9 @@ pub enum Response { Frame { frame: replay::Frame, }, + Inspect { + inspection: Box, + }, CustomProgress { progress: crate::custom_progress::LongProgress, }, @@ -67,6 +71,8 @@ pub struct Overview { pub input_history: Vec, pub corrected_words_history: Vec, pub missed_words: Vec<(Text, usize)>, + // serde_json writes positive infinity as null; the browser restores its display value. + #[ts(type = "Array")] pub word_burst_history: Vec, pub raw_history: Vec, pub keypresses_per_second: Vec, @@ -77,6 +83,40 @@ pub struct Overview { pub date_duration_ms: f64, pub word_indexes_per_second: Vec>, } +#[derive(Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct Inspection { + pub accuracy: stats::Accuracy, + pub key_spacing: Vec, + pub key_duration: Vec, + pub boundary_labels: Vec, + pub inputs: Vec, + pub word_index: i32, +} +fn inspect(log: &Log) -> Inspection { + let timing = stats::key_timing(log); + let mut inputs = vec![Text::default(); log.context.target_words.len()]; + let mut word_index = -1; + // Total observed input text is bounded before this scan. Preserve each last + // DOM snapshot, including legacy final-commit trim, without a second index. + for event in &log.events { + if event.kind == "input" { + let index = stats::word_index(event).expect("Validated input index"); + if let Some(value) = inputs.get_mut(index) { + *value = stats::input_value(&event.data); + } + word_index = index as i32; + } + } + Inspection { + accuracy: stats::accuracy_counts(log, None), + key_spacing: timing.spacing, + key_duration: timing.durations, + boundary_labels: stats::boundary_labels(log, false), + inputs, + word_index, + } +} pub fn analyze(log: &Log, query: Query) -> Result { crate::validation::log(log)?; match &query { @@ -138,6 +178,9 @@ pub fn analyze(log: &Log, query: Query) -> Result Response::Inspect { + inspection: Box::new(inspect(log)), + }, Query::Frame { time } => Response::Frame { frame: replay::frame(log, time), }, diff --git a/crates/typing-engine/src/bin/export-bindings.rs b/crates/typing-engine/src/bin/export-bindings.rs index e903f81b2a..1b76d0a959 100644 --- a/crates/typing-engine/src/bin/export-bindings.rs +++ b/crates/typing-engine/src/bin/export-bindings.rs @@ -49,5 +49,6 @@ fn main() -> Result<(), Box> { typing_engine::generation::Generated::export_all(&config)?; typing_engine::generation::NextWord::export_all(&config)?; typing_engine::quotes::Quote::export_all(&config)?; + typing_engine::quotes::QuoteCollection::export_all(&config)?; Ok(()) } diff --git a/crates/typing-engine/src/content.rs b/crates/typing-engine/src/content.rs index 100190952a..209199355b 100644 --- a/crates/typing-engine/src/content.rs +++ b/crates/typing-engine/src/content.rs @@ -211,7 +211,10 @@ impl Queue { } let section = source.normalize(self.english)?; if section.words.is_empty() { - return Err("Section has no words".into()); + return Err(crate::error::EngineError::new( + crate::error::ErrorCode::EmptyContent, + "Section has no words", + )); } self.ready.push_back(section); Ok(()) diff --git a/crates/typing-engine/src/error.rs b/crates/typing-engine/src/error.rs index 31b5ca6071..2c2df6936e 100644 --- a/crates/typing-engine/src/error.rs +++ b/crates/typing-engine/src/error.rs @@ -11,6 +11,7 @@ pub enum ErrorCode { Disposed, BindingMismatch, ContentRequired, + EmptyContent, StaleRequest, NoQuotes, SelectedQuoteMissing, diff --git a/crates/typing-engine/src/generation.rs b/crates/typing-engine/src/generation.rs index f633ed28c4..6c2f5acab9 100644 --- a/crates/typing-engine/src/generation.rs +++ b/crates/typing-engine/src/generation.rs @@ -37,7 +37,7 @@ pub struct NextWord { pub word_raw: Text, pub section_index: usize, } -#[derive(Debug, Serialize, TS)] +#[derive(Clone, Debug, Serialize, TS)] #[serde(rename_all = "camelCase")] pub struct Generated { pub words: Vec, @@ -987,6 +987,7 @@ pub struct Prepare { /// Retains only explicitly owned content-selection state across restarts. #[derive(Default)] pub struct Content { + prepared: Option<(bool, Generated)>, assets: Assets, generator: Option, weak_spot: crate::weak_spot::WeakSpot, @@ -1008,6 +1009,29 @@ impl Content { self.generation = self.generation.max(memory.generation); self.has_prepared = true; self.generator = None; + self.prepared = None; + } + pub fn quote_catalog( + &mut self, + asset: crate::quotes::QuoteAsset, + lengths: Option>, + mut random: Random, + ) -> Result { + crate::validation::random(&random)?; + if lengths.as_ref().is_some_and(|lengths| { + lengths.len() > 64 + || lengths + .iter() + .any(|length| ![-3, -2, 0, 1, 2, 3].contains(length)) + }) { + return Err(EngineError::new( + ErrorCode::InvalidConfig, + "Invalid quote length selection", + )); + } + self.assets + .quotes + .load_catalog(asset, lengths.as_deref(), &mut random) } pub fn update_quote_queue( &mut self, @@ -1047,6 +1071,7 @@ impl Content { } self.has_prepared = true; self.generator = None; + self.prepared = None; self.assets.quote_asset = input.quotes; self.assets.polyglot = input.polyglot; self.assets.selected_quote_id = input.selected_quote_id; @@ -1077,13 +1102,27 @@ impl Content { &mut self, repeating: bool, ) -> Result { - let generated = self.generator()?.generate(repeating)?; + let generated = match self.prepared.take() { + Some((prepared_repeat, generated)) if prepared_repeat == repeating => generated, + _ => self.generator()?.generate(repeating)?, + }; let generator = self.generator.take().ok_or("Content not prepared")?; let mut session = crate::Session::from_generated(generator, generated)?; session.pace.remember(self.last_pace_wpm, false); Ok(session) } - pub fn generator(&mut self) -> Result<&mut Generator, crate::error::EngineError> { + /// Inspect prepared output without restarting generation or consuming RNG/source content. + pub fn generate(&mut self, repeating: bool) -> Result { + let generated = self.generator()?.generate(repeating)?; + self.prepared = Some((repeating, generated.clone())); + Ok(generated) + } + pub fn prepared_generator(&self) -> Result<&Generator, EngineError> { + self.generator.as_ref().ok_or("Content not prepared".into()) + } + /// Mutable generator access invalidates any prior presentation snapshot. + pub fn generator(&mut self) -> Result<&mut Generator, EngineError> { + self.prepared = None; self.generator.as_mut().ok_or("Content not prepared".into()) } } diff --git a/crates/typing-engine/src/language_rules.rs b/crates/typing-engine/src/language_rules.rs index 202f68a9f0..46a973d12c 100644 --- a/crates/typing-engine/src/language_rules.rs +++ b/crates/typing-engine/src/language_rules.rs @@ -1,20 +1,8 @@ use crate::{Text, generation::case}; -use serde::Deserialize; -use std::collections::BTreeMap; +use serde_json::Value; pub struct LanguageRules { accents: Vec<(Text, Text)>, - british: BTreeMap, -} -#[derive(Deserialize)] -#[serde(untagged)] -enum BritishRule { - Word(String), - WithExceptions { - #[serde(rename = "britishWord")] - word: String, - #[serde(rename = "exceptPreviousWords")] - previous: Vec, - }, + british: Value, } impl LanguageRules { pub fn new() -> Result { @@ -102,20 +90,23 @@ impl LanguageRules { let Some(rule) = self.british.get(&key) else { return word; }; - let (replacement, exceptions) = match rule { - BritishRule::Word(word) => (word, &[][..]), - BritishRule::WithExceptions { word, previous } => (word, previous.as_slice()), - }; + let replacement = rule + .as_str() + .or_else(|| rule["britishWord"].as_str()) + .expect("Checked immutable British dictionary"); if quote && previous.is_some_and(|previous| { - exceptions - .iter() - .any(|exception| Text::from(exception.as_str()) == *previous) + rule.get("exceptPreviousWords") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .any(|exception| Text::from(exception) == *previous) }) { return word; } - let mut replacement = Text::from(replacement.as_str()); + let mut replacement = Text::from(replacement); if case(&Text(vec![middle.0[0]]), true).0 == [middle.0[0]] { if case(&middle, true) == middle { replacement = case(&replacement, true); diff --git a/crates/typing-engine/src/policy.rs b/crates/typing-engine/src/policy.rs index 254b3f4489..3b9c3661d4 100644 --- a/crates/typing-engine/src/policy.rs +++ b/crates/typing-engine/src/policy.rs @@ -10,6 +10,16 @@ use ts_rs::TS; #[serde(tag = "op", rename_all = "camelCase")] #[ts(rename = "PolicyQuery")] pub enum Query { + #[serde(rename_all = "camelCase")] + QuickRestart { + config: Box, + limit: Option, + custom_is_long: bool, + }, + SettingConflicts { + names: Vec, + values: Vec<(String, Value)>, + }, Compatible { names: Vec, }, @@ -28,15 +38,42 @@ pub enum Query { #[serde(tag = "op", rename_all = "camelCase")] #[ts(rename = "PolicyResponse")] pub enum Response { + QuickRestart { result: bool }, + SettingConflicts { keys: Vec }, Compatible { result: bool }, Forced { result: funboxes::Forced }, CanSet { result: bool }, } pub fn evaluate(query: Query) -> Result { - let names = match &query { - Query::Compatible { names } | Query::Forced { names, .. } | Query::CanSet { names, .. } => { - names + if let Query::QuickRestart { + config, + limit, + custom_is_long, + } = &query + { + config + .validate() + .map_err(|message| EngineError::new(ErrorCode::InvalidConfig, message))?; + if limit.as_ref().is_some_and(|limit| { + !limit.value.is_finite() + || limit.value < 0.0 + || !["word", "time", "section"].contains(&limit.mode.as_str()) + }) { + return Err(EngineError::new( + ErrorCode::InvalidConfig, + "Invalid custom limit", + )); } + return Ok(Response::QuickRestart { + result: crate::restart::can_quick_restart(config, limit.as_ref(), *custom_is_long), + }); + } + let names = match &query { + Query::QuickRestart { .. } => unreachable!(), + Query::SettingConflicts { names, .. } + | Query::Compatible { names } + | Query::Forced { names, .. } + | Query::CanSet { names, .. } => names, }; if names.len() > 64 || names.iter().any(|name| name.len() > 128) { return Err(EngineError::new( @@ -55,6 +92,23 @@ pub fn evaluate(query: Query) -> Result { } let error = |message| EngineError::new(ErrorCode::InvalidConfig, message); Ok(match query { + Query::QuickRestart { .. } => unreachable!(), + Query::SettingConflicts { names, values } => { + if values.len() > 1024 { + return Err(error("Setting count exceeds bound".into())); + } + let mut keys = vec![]; + for (key, value) in values { + if key.len() > 128 { + return Err(error("Setting key exceeds bound".into())); + } + crate::validation::json_value(&value)?; + if !funboxes::can_set(&key, &value, &names).map_err(error)? { + keys.push(key); + } + } + Response::SettingConflicts { keys } + } Query::Compatible { names } => Response::Compatible { result: funboxes::compatible(&names), }, diff --git a/crates/typing-engine/src/quotes.rs b/crates/typing-engine/src/quotes.rs index c91a030fa3..126da3e752 100644 --- a/crates/typing-engine/src/quotes.rs +++ b/crates/typing-engine/src/quotes.rs @@ -8,6 +8,7 @@ use ts_rs::TS; pub struct Quote { pub text: Text, #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub british_text: Option, pub source: String, pub length: usize, @@ -25,6 +26,13 @@ pub struct QuoteAsset { pub groups: Vec<(usize, usize)>, pub quotes: Vec, } +#[derive(Default, Serialize, TS)] +pub struct QuoteCollection { + pub quotes: Vec, + pub length: usize, + pub language: Option, + pub groups: Vec>, +} /// Host-owned lifetime, engine-owned selection; never a process-global queue. #[derive(Default)] pub struct Quotes { @@ -32,6 +40,7 @@ pub struct Quotes { quotes: Vec, groups: Vec>, queue: Vec, + queued_quotes: Option>, cursor: usize, } impl Quotes { @@ -90,12 +99,69 @@ impl Quotes { self.quotes = asset.quotes; self.update_queue(lengths, random) } + pub fn collection(&self) -> QuoteCollection { + QuoteCollection { + quotes: self.quotes.clone(), + length: self.quotes.len(), + language: self.language.clone(), + groups: self.groups.clone(), + } + } + /// Legacy catalog reads replace the collection but preserve an already-built + /// queue unless lengths were explicitly supplied. Old queue objects retain + /// their original language/content even after the catalog changes. + pub fn load_catalog( + &mut self, + asset: QuoteAsset, + lengths: Option<&[i32]>, + random: &mut Random, + ) -> Result { + crate::validation::quote_asset(&asset)?; + if self.language.as_ref() == Some(&asset.language) { + return Ok(self.collection()); + } + if asset.quotes.is_empty() { + return Ok(QuoteCollection::default()); + } + let retained = if lengths.is_none() { + Some(( + self.queue.clone(), + self.cursor, + self.queued_quotes + .take() + .unwrap_or_else(|| self.quotes.clone()), + )) + } else { + None + }; + self.load(asset, lengths.unwrap_or_default(), random)?; + if let Some((queue, cursor, quotes)) = retained { + self.queue = queue; + self.cursor = cursor; + self.queued_quotes = Some(quotes); + } + Ok(self.collection()) + } + pub fn random_quote(&mut self, random: &mut Random) -> Result, EngineError> { + if self.queue.is_empty() { + return Ok(None); + } + if self.cursor >= self.queue.len() { + self.cursor = 0; + random.shuffle(&mut self.queue)?; + } + let quotes = self.queued_quotes.as_ref().unwrap_or(&self.quotes); + let quote = quotes[self.queue[self.cursor]].clone(); + self.cursor += 1; + Ok(Some(quote)) + } pub fn update_queue( &mut self, lengths: &[i32], random: &mut Random, ) -> Result<(), crate::error::EngineError> { self.queue.clear(); + self.queued_quotes = None; for group in lengths { if *group >= 0 && let Some(indexes) = self.groups.get(*group as usize) @@ -116,6 +182,7 @@ impl Quotes { british: bool, random: &mut Random, ) -> Result { + let queued = lengths != [-2] && !lengths.contains(&-3); let mut quote = if lengths == [-2] { self.quotes .iter() @@ -156,13 +223,7 @@ impl Quotes { vec![Recovery::SetQuoteLengthAll], )); } - if self.cursor >= self.queue.len() { - self.cursor = 0; - random.shuffle(&mut self.queue)?; - } - let quote = self.quotes[self.queue[self.cursor]].clone(); - self.cursor += 1; - quote + self.random_quote(random)?.unwrap() }; quote.language = remove_language_size(language); quote.text = normalize(quote.text); @@ -180,6 +241,14 @@ impl Quotes { .split(|unit| *unit == 32) .map(|word| Text(word.to_vec())) .collect(); + // The original selected object is also visible to catalog reads. Keep + // its normalized text/language/split, including a retained older queue. + if queued { + let index = self.queue[self.cursor - 1]; + self.queued_quotes.as_mut().unwrap_or(&mut self.quotes)[index] = quote.clone(); + } else if let Some(stored) = self.quotes.iter_mut().find(|stored| stored.id == quote.id) { + *stored = quote.clone(); + } Ok(quote) } } diff --git a/crates/typing-engine/src/session.rs b/crates/typing-engine/src/session.rs index 3a46776b3d..47be01a2e5 100644 --- a/crates/typing-engine/src/session.rs +++ b/crates/typing-engine/src/session.rs @@ -317,6 +317,9 @@ pub struct Preflight { pub blocked: bool, pub measure_overflow: bool, pub normalized: Text, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub expanded: Option, } #[derive(Clone, Serialize, TS)] pub struct FinishRequest { @@ -352,6 +355,12 @@ pub struct Event { #[derive(Serialize, TS)] pub struct Update { pub state: State, + #[serde(rename = "inputDisplayValue", skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub input_display_value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub metadata: Option, pub events: Vec, pub generation: u32, #[serde(rename = "automaticPending")] @@ -379,6 +388,13 @@ pub struct Update { #[ts(optional)] pub drift: Option, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionMetadata { + pub all_words_generated: bool, + pub has_tab: bool, + pub has_newline: bool, +} #[derive(Clone, Serialize, TS)] #[serde(rename_all = "camelCase")] pub struct WordChange { @@ -411,6 +427,7 @@ pub struct LiveMetrics { pub seconds: f64, pub duration_ms: f64, pub timer_start_ms: Option, + pub time_limit_seconds: Option, pub ms_since_last_input: Option, } @@ -471,6 +488,7 @@ pub struct Session { no_code_index: usize, completion_checkpoint: bool, composing: bool, + clear_input_display: bool, earliest_visible_word: usize, defer_automatic: bool, automatic: VecDeque<(f64, f64)>, @@ -520,9 +538,32 @@ impl Session { seconds: seconds.floor(), duration_ms, timer_start_ms: self.start, + time_limit_seconds: self.time_limit(), ms_since_last_input: self.live.spacing(), }) } + pub fn time_limit(&self) -> Option { + if self.config.mode == "time" { + Some(self.config.time) + } else if self.config.mode == "custom" { + self.custom_limit + .as_ref() + .filter(|limit| limit.mode == "time") + .map(|limit| limit.value) + } else { + None + } + } + pub fn word_burst(&self, index: usize, now: f64) -> Result { + if !now.is_finite() + || now.abs() > 9_007_199_254_740_991.0 + || index > crate::validation::MAX_WORDS + { + return Err("Invalid live word query".into()); + } + let origin = self.start.unwrap_or_else(|| self.origin()); + Ok(self.live.burst(index, origin, now - origin)) + } pub fn restart_policy( &self, options: crate::restart::RestartOptions, @@ -602,6 +643,7 @@ impl Session { no_code_index: 0, completion_checkpoint: true, composing: false, + clear_input_display: false, earliest_visible_word: 0, defer_automatic: false, automatic: VecDeque::new(), @@ -659,12 +701,28 @@ impl Session { session.word_changes.clear(); Ok(session) } + fn expansion(&self, unit: u16, target: Option) -> Option<&'static str> { + if unit == 0x2026 && target != Some(unit) { + Some("...") + } else if unit == 0x0133 + && crate::text::base_language(&self.config.language) == "dutch" + && target != Some(unit) + { + Some("ij") + } else { + None + } + } pub fn preflight(&self, data: Text, layout: &InsertionLayout) -> Preflight { let target = self.target(); let input = self.current(); let observation = crate::input::Input { data, - input_value: input.clone(), + input_value: if self.clear_input_display { + Text::default() + } else { + input.clone() + }, target_word: target.clone(), nospace: self.nospace(), correct_shift_used: None, @@ -678,6 +736,7 @@ impl Session { || self.awaiting_recovery || self.pending_commit.is_some() || self.pending_finish.is_some() + || observation.data.len() > 65536 || crate::input::blocked(&self.config, &observation, self.has_newline); let display_length = target.len() - usize::from(target.0.last() == Some(&32)); let measure_overflow = !blocked @@ -690,11 +749,43 @@ impl Session { && input.len() >= display_length && !crate::input::should_advance(&self.config, &observation) && self.config.mode != "zen"; + let expanded = if normalized.len() == 1 { + self.expansion(normalized.0[0], target.0.get(input.len()).copied()) + .map(Text::from) + } else { + None + }; Preflight { blocked: blocked || (measure_overflow && layout.wrapped), measure_overflow, normalized, + expanded, + } + } + pub fn metadata(&self) -> SessionMetadata { + SessionMetadata { + all_words_generated: self.all_generated, + has_tab: self.has_tab, + has_newline: self.has_newline, + } + } + pub fn composition_completes(&self, data: &Text) -> Result { + if data.len() > 65536 { + return Err("Composition exceeds input bound".into()); } + if !self.active + || self.ended + || self.awaiting_recovery + || self.pending_finish.is_some() + || self.pending_commit.is_some() + || !self.all_generated + || self.index + 1 < self.words.len() + { + return Ok(false); + } + let mut combined = self.current().0; + combined.extend_from_slice(&data.0); + Ok(self.target().0 == combined) } pub fn snapshot(&self) -> Snapshot { Snapshot { @@ -725,6 +816,9 @@ impl Session { } } fn refill(&mut self) -> Result<(), crate::error::EngineError> { + if self.config.mode == "zen" { + return Ok(()); + } let bound = crate::funboxes::to_push(&self.config.funbox) .map_or(100, |count| count.saturating_sub(1)); if self.all_generated || self.words.len().saturating_sub(self.index + 1) > bound { @@ -925,6 +1019,7 @@ impl Session { )); } let before = self.events.len(); + let previous_metadata = self.metadata(); let previous_origin = self.origin(); let invalidated = matches!(command, Command::Finalize { .. } | Command::Reset { .. }); self.word_changes.clear(); @@ -948,6 +1043,8 @@ impl Session { if self.preflight(data.clone(), &layout).blocked { return Ok(Update { state: self.state(), + input_display_value: self.clear_input_display.then(Text::default), + metadata: None, events: vec![], generation: self.generation, automatic_pending: !self.automatic.is_empty(), @@ -1098,6 +1195,7 @@ impl Session { self.no_code_index = 0; self.pace.reset(None); self.composing = false; + self.clear_input_display = false; self.earliest_visible_word = 0; self.automatic.clear(); self.generation = self.generation.wrapping_add(1); @@ -1151,6 +1249,8 @@ impl Session { .collect(); Ok(Update { state: self.state(), + input_display_value: self.clear_input_display.then(Text::default), + metadata: (self.metadata() != previous_metadata).then(|| self.metadata()), events, generation: self.generation, automatic_pending: !self.automatic.is_empty(), @@ -1248,7 +1348,18 @@ impl Session { nospace, correct_shift_used: shift, }; - if crate::input::blocked(&self.config, &observation, self.has_newline) { + if crate::input::blocked( + &self.config, + &crate::input::Input { + input_value: if self.clear_input_display { + Text::default() + } else { + input.clone() + }, + ..observation.clone() + }, + self.has_newline, + ) { return; } let unit = normalize( @@ -1256,16 +1367,7 @@ impl Session { target.0.get(input.len()).copied(), &self.config.language, ); - let replacement = if unit == 0x2026 && target.0.get(input.len()) != Some(&unit) { - Some("...") - } else if unit == 0x0133 - && crate::text::base_language(&self.config.language) == "dutch" - && target.0.get(input.len()) != Some(&unit) - { - Some("ij") - } else { - None - }; + let replacement = self.expansion(unit, target.0.get(input.len()).copied()); if let Some(replacement) = replacement { let checkpoint = self.completion_checkpoint; let replacement: Vec<_> = replacement.encode_utf16().collect(); @@ -1287,7 +1389,16 @@ impl Session { let mut raw_next = input.clone(); raw_next.0.push(unit); let word_correct = raw_next == target; - let next = decision.input_value; + let next = if self.clear_input_display { + if decision.stopped { + Text::default() + } else { + Text(vec![unit]) + } + } else { + decision.input_value + }; + self.clear_input_display = false; self.visual_input_override = decision.visual_input_override; let correct = decision.correct; let stopped = decision.stopped; @@ -1373,6 +1484,9 @@ impl Session { let checkpoint = checkpoint && !self.composing; // The actual browser navigates before checking difficulty/minimum burst. let committed_index = self.index; + if advance { + self.clear_input_display = true; + } let progressed = advance && (self.index + 1 < self.words.len() || self.config.mode == "zen"); if progressed { @@ -1493,35 +1607,42 @@ impl Session { } } - fn delete(&mut self, kind: &str, now: f64, observed: Option, sentinel_removed: bool) { + /// Pure beforeinput guard; deletion repeats this check before mutation. + pub fn can_delete(&self) -> bool { if !self.active || self.ended || self.awaiting_recovery || self.pending_commit.is_some() || self.pending_finish.is_some() { - return; - } - let input = self.current(); - if input.is_empty() && self.index <= self.earliest_visible_word { - return; + return false; } - let prev = self + let input = if self.clear_input_display { + Text::default() + } else { + self.current() + }; + let empty = Text::default(); + let previous = self .index .checked_sub(1) .and_then(|index| self.input.get(index)) - .cloned() - .unwrap_or_default(); - let previous_correct = self.index > 0 && self.words.get(self.index - 1) == Some(&prev); - if !crate::input::can_delete( + .unwrap_or(&empty); + let previous_correct = self.index > 0 && self.words.get(self.index - 1) == Some(previous); + crate::input::can_delete( &self.config, &input, self.index, previous_correct, self.index > self.earliest_visible_word, - ) { + ) + } + fn delete(&mut self, kind: &str, now: f64, observed: Option, sentinel_removed: bool) { + if !self.can_delete() { return; } + self.clear_input_display = false; + let input = self.current(); let after = observed.unwrap_or_else(|| { if kind == "deleteWordBackward" { delete_word(&input) @@ -1643,16 +1764,7 @@ impl Session { self.finish(now, date, Some(reason.into())); } } - let limit = if self.config.mode == "time" { - Some(self.config.time) - } else if self.config.mode == "custom" { - self.custom_limit - .as_ref() - .filter(|limit| limit.mode == "time") - .map(|limit| limit.value) - } else { - None - }; + let limit = self.time_limit(); if limit.is_some_and(|limit| limit > 0.0 && f64::from(self.ticks) >= limit) { self.finish(now, date, None); } diff --git a/crates/typing-engine/src/stats.rs b/crates/typing-engine/src/stats.rs index bbbf3ef5ae..48a2903e80 100644 --- a/crates/typing-engine/src/stats.rs +++ b/crates/typing-engine/src/stats.rs @@ -191,26 +191,40 @@ pub fn wpm(chars: usize, seconds: f64) -> f64 { chars as f64 / 5.0 / (seconds / 60.0) } } -pub fn accuracy(log: &Log, limit: Option) -> f64 { - let mut correct = 0; - let mut total = 0; +#[derive(Serialize, TS)] +pub struct Accuracy { + pub correct: usize, + pub incorrect: usize, + pub percentage: f64, +} +pub fn accuracy_counts(log: &Log, limit: Option) -> Accuracy { + let mut counts = Accuracy { + correct: 0, + incorrect: 0, + percentage: 0.0, + }; for event in &log.events { if limit.is_some_and(|limit| event.test_ms > limit) { break; } if event.kind == "input" && event.data.get("correct").is_some() { - total += 1; if event.data["correct"] == true { - correct += 1; + counts.correct += 1; + } else { + counts.incorrect += 1; } } } - if total == 0 { - 0.0 - } else { - correct as f64 / total as f64 * 100.0 + let total = counts.correct + counts.incorrect; + if total != 0 { + counts.percentage = counts.correct as f64 / total as f64 * 100.0; } + counts +} +pub fn accuracy(log: &Log, limit: Option) -> f64 { + accuracy_counts(log, limit).percentage } + fn end(log: &Log) -> f64 { log.events .iter() @@ -444,51 +458,12 @@ pub(crate) fn build_summary(log: &Log) -> Summary { && event.data["inputType"] == "insertText" && event.data["correct"] == false }); - let mut spacing = vec![]; - let mut last = None; - let mut durations: Vec = vec![]; - let mut held: Vec<(String, f64, usize)> = vec![]; - let mut overlap = 0.0; - let mut overlap_start = None; - let start = log - .events - .iter() - .find(|event| event.kind == "timer" && event.data["event"] == "start") - .map(|event| event.test_ms); - let first = log - .events - .iter() - .find(|event| event.kind == "keydown") - .map(|event| (event.test_ms - start.unwrap_or(event.test_ms)).max(0.0)) - .unwrap_or(0.0); - for event in &log.events { - let code = event.data["code"].as_str().unwrap_or_default().to_owned(); - if event.kind == "keydown" { - if let Some(previous) = last { - spacing.push(event.test_ms - previous); - } - last = Some(event.test_ms.max(0.0)); - if let Some(index) = held.iter().position(|(key, _, _)| key == &code) { - held.remove(index); - } - held.push((code, event.test_ms, durations.len())); - durations.push(0.0); - if overlap_start.is_none() && held.len() > 1 { - overlap_start = Some(event.test_ms); - } - } else if event.kind == "keyup" { - if let Some(index) = held.iter().position(|(key, _, _)| key == &code) { - let (_, time, index) = held.remove(index); - durations[index] = event.test_ms - time; - } - if let Some(start) = overlap_start - && held.len() == 1 - { - overlap += event.test_ms - start; - overlap_start = None; - } - } - } + let KeyTiming { + spacing, + durations, + overlap, + first, + } = key_timing(log); Summary { wpm: round2(wpm(counts.correct_word, seconds)), raw_wpm: round2(wpm( @@ -693,6 +668,7 @@ pub fn lagged_boundaries(log: &Log) -> Vec { } boundaries } +#[inline(never)] pub fn boundary_labels(log: &Log, show_lag: bool) -> Vec { let boundaries = boundaries(log); boundaries @@ -721,3 +697,64 @@ pub fn boundary_labels(log: &Log, show_lag: bool) -> Vec { }) .collect() } + +pub struct KeyTiming { + pub spacing: Vec, + pub durations: Vec, + pub overlap: f64, + pub first: f64, +} +#[inline(never)] +pub fn key_timing(log: &Log) -> KeyTiming { + let mut spacing = vec![]; + let mut last = None; + let mut durations: Vec = vec![]; + let mut held: Vec<(&str, f64, usize)> = vec![]; + let mut overlap = 0.0; + let mut overlap_start = None; + let start = log + .events + .iter() + .find(|event| event.kind == "timer" && event.data["event"] == "start") + .map(|event| event.test_ms); + let first = log + .events + .iter() + .find(|event| event.kind == "keydown") + .map(|event| (event.test_ms - start.unwrap_or(event.test_ms)).max(0.0)) + .unwrap_or(0.0); + for event in &log.events { + let code = event.data["code"].as_str().unwrap_or_default(); + if event.kind == "keydown" { + if let Some(previous) = last { + spacing.push(event.test_ms - previous); + } + last = Some(event.test_ms.max(0.0)); + if let Some(index) = held.iter().position(|(key, _, _)| key == &code) { + held.remove(index); + } + held.push((code, event.test_ms, durations.len())); + durations.push(0.0); + if overlap_start.is_none() && held.len() > 1 { + overlap_start = Some(event.test_ms); + } + } else if event.kind == "keyup" { + if let Some(index) = held.iter().position(|(key, _, _)| key == &code) { + let (_, time, index) = held.remove(index); + durations[index] = event.test_ms - time; + } + if let Some(start) = overlap_start + && held.len() == 1 + { + overlap += event.test_ms - start; + overlap_start = None; + } + } + } + KeyTiming { + spacing, + durations, + overlap, + first, + } +} diff --git a/crates/typing-engine/tests/analysis.rs b/crates/typing-engine/tests/analysis.rs index e0a3846e02..bf56fc4fc9 100644 --- a/crates/typing-engine/tests/analysis.rs +++ b/crates/typing-engine/tests/analysis.rs @@ -182,3 +182,30 @@ fn replay_seeking_practice_and_validity_match_frozen_typescript_oracle() { } } } + +#[test] +fn inspector_preserves_64_original_dom_timing_and_accuracy_prefixes() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../packages/typing-fixtures/oracle/inspection.json"); + let cases: Vec = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + for case in cases { + let log: Log = serde_json::from_value(case["log"].clone()).unwrap(); + let result = + typing_engine::analysis::analyze(&log, typing_engine::analysis::Query::Inspect) + .unwrap(); + let mut expected = case["expected"].clone(); + expected["inputs"] = json!( + expected["inputs"] + .as_array() + .unwrap() + .iter() + .map(|value| Text::from(value.as_str().unwrap())) + .collect::>() + ); + equal( + &json!(result)["inspection"], + &expected, + case["name"].as_str().unwrap(), + ); + } +} diff --git a/crates/typing-engine/tests/browser_queries.rs b/crates/typing-engine/tests/browser_queries.rs new file mode 100644 index 0000000000..58ec5afff9 --- /dev/null +++ b/crates/typing-engine/tests/browser_queries.rs @@ -0,0 +1,151 @@ +use serde_json::json; +use typing_engine::{Command, Config, Session, Text}; +fn command(input: serde_json::Value) -> Command { + serde_json::from_value(input).unwrap() +} + +#[test] +fn composition_quick_end_uses_owned_finite_targets_and_exact_utf16() { + let mut session = Session::new( + Config { + mode: "words".into(), + words: 1, + ..Config::default() + }, + vec![Text(vec![0xd800, 0xdc00])], + ) + .unwrap(); + assert!( + !session + .composition_completes(&Text(vec![0xd800, 0xdc00])) + .unwrap() + ); + session + .apply(command(json!({"op":"start","now":1}))) + .unwrap(); + assert!( + session + .composition_completes(&Text(vec![0xd800, 0xdc00])) + .unwrap() + ); + session + .apply(command(json!({"op":"insert","now":2,"data":[0xd800]}))) + .unwrap(); + assert!(session.composition_completes(&Text(vec![0xdc00])).unwrap()); + assert!(!session.composition_completes(&Text::from("x")).unwrap()); + session.apply(command(json!({"op":"end","now":3}))).unwrap(); + assert!(!session.composition_completes(&Text(vec![0xdc00])).unwrap()); + assert!( + session + .composition_completes(&Text(vec![97; 65537])) + .is_err() + ); + let mut infinite = Session::new( + Config { + mode: "words".into(), + words: 0, + ..Config::default() + }, + vec![Text::from("one ")], + ) + .unwrap(); + infinite + .apply(command(json!({"op":"start","now":0}))) + .unwrap(); + assert!(!infinite.composition_completes(&Text::from("one ")).unwrap()); +} +#[test] +fn metadata_deltas_report_changed_generation_flags_without_copying_targets() { + let mut session = Session::new( + Config { + mode: "custom".into(), + ..Config::default() + }, + vec![Text::from("\ta\n")], + ) + .unwrap(); + assert!(session.metadata().all_words_generated); + let update = session + .apply(command( + json!({"op":"customLimit","mode":"time","value":1.5,"now":0}), + )) + .unwrap(); + let metadata = update.metadata.unwrap(); + assert!(!metadata.all_words_generated); + assert!(metadata.has_tab && metadata.has_newline); + assert_eq!( + session.live_stats(0.0).unwrap().time_limit_seconds, + Some(1.5) + ); + let observation = session + .apply(command( + json!({"op":"observe","eventType":"keydown","eventData":{"code":"KeyA"},"now":1}), + )) + .unwrap(); + assert!(observation.metadata.is_none()); + assert!(observation.words.is_empty()); + let update = session + .apply(command( + json!({"op":"customLimit","mode":"word","value":1,"now":2}), + )) + .unwrap(); + assert!(update.metadata.unwrap().all_words_generated); + assert_eq!(session.live_stats(2.0).unwrap().time_limit_seconds, None); +} + +#[test] +fn final_word_batch_matches_immutable_browser_dom_observation() { + let oracle: serde_json::Value = serde_json::from_str(include_str!( + "../../../packages/typing-fixtures/oracle/browser-final-word-batch.json" + )) + .unwrap(); + let mut session = Session::new( + Config { + mode: "words".into(), + words: 1, + ..Config::default() + }, + vec![Text::from("one")], + ) + .unwrap(); + let update = session + .apply(command( + json!({"op":"insert","now":0,"data":Text::from("one extra")}), + )) + .unwrap(); + let mut inputs = serde_json::to_value(update.events).unwrap(); + let inputs = inputs.as_array_mut().unwrap(); + inputs.retain(|event| event["type"] == "input"); + for event in inputs.iter_mut() { + for key in ["data", "inputValue"] { + let units: Vec = serde_json::from_value(event["data"][key].clone()).unwrap(); + event["data"][key] = json!(String::from_utf16(&units).unwrap()); + } + } + let expected = oracle["input"].as_array().unwrap(); + assert_eq!(inputs.len(), expected.len()); + for (actual, expected) in inputs.iter().zip(expected) { + assert_eq!(actual["type"], expected["type"]); + assert_eq!(actual["testMs"].as_f64(), expected["testMs"].as_f64()); + assert_eq!(actual["data"], expected["data"]); + } + assert_eq!(session.state().input_value, Text::from("extra")); +} + +#[test] +fn preflight_expansion_uses_the_same_target_dependent_rule_as_insertion() { + let session = Session::new(Config::default(), vec![Text::from("... ")]).unwrap(); + assert_eq!( + session + .preflight(Text::from("…"), &Default::default()) + .expanded, + Some(Text::from("...")) + ); + let literal = Session::new(Config::default(), vec![Text::from("… ")]).unwrap(); + assert_eq!( + literal + .preflight(Text::from("…"), &Default::default()) + .expanded, + None + ); +} diff --git a/crates/typing-engine/tests/content.rs b/crates/typing-engine/tests/content.rs index b90a112828..cafb4d55d6 100644 --- a/crates/typing-engine/tests/content.rs +++ b/crates/typing-engine/tests/content.rs @@ -624,3 +624,26 @@ fn active_source_failure_preserves_captured_input_and_never_scores_an_io_failure assert!(session.log()["events"].as_array().unwrap().is_empty()); } } + +#[test] +fn inspecting_generated_content_then_starting_uses_one_source_and_rng_sequence() { + let mut content = typing_engine::generation::Content::default(); + content + .prepare(source_preparation(vec![poem(&vec!["one"; 100].join(" "))])) + .unwrap(); + let generated = content.generate(false).unwrap(); + assert!( + content + .prepared_generator() + .unwrap() + .pending_request() + .is_none() + ); + let session = content.start_session(false).unwrap(); + assert_eq!(session.snapshot().words, generated.words); + assert_eq!( + session.snapshot().section_indexes, + generated.section_indexes + ); + assert!(content.start_session(false).is_err()); +} diff --git a/crates/typing-engine/tests/empty_source.rs b/crates/typing-engine/tests/empty_source.rs new file mode 100644 index 0000000000..87210248f9 --- /dev/null +++ b/crates/typing-engine/tests/empty_source.rs @@ -0,0 +1,43 @@ +use serde_json::{Value, json}; +use typing_engine::{ + Text, + content::{Queue, Source}, + error::ErrorCode, +}; + +#[test] +fn empty_wikipedia_cleanup_keeps_one_retryable_request_until_frozen_nonempty_article() { + let fixture: Value = serde_json::from_slice( + &std::fs::read( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../packages/typing-fixtures/oracle/wikipedia-retry.json"), + ) + .unwrap(), + ) + .unwrap(); + let mut queue = Queue::new("wikipedia".into(), "english".into(), true); + let request = queue.request(); + for (index, text) in fixture["extracts"].as_array().unwrap().iter().enumerate() { + let result = queue.supply( + &request, + Source::Article { + title: Text::from(format!("Article {}", index + 1).as_str()), + author: Some(Text::from("Writer")), + plain_text: Text::from(text.as_str().unwrap()), + }, + ); + if index == 0 { + assert_eq!(result.unwrap_err().code, ErrorCode::EmptyContent); + assert_eq!(queue.pending(), Some(&request)); + assert!(queue.pop().is_none()); + } else { + result.unwrap(); + assert!(queue.pending().is_none()); + let section = queue.pop().unwrap(); + assert_eq!( + json!({"title":section.title.display_lossy(), "author":section.author.unwrap().display_lossy(), "words":section.words.iter().map(Text::display_lossy).collect::>()}), + fixture["expected"] + ); + } + } +} diff --git a/crates/typing-engine/tests/funboxes.rs b/crates/typing-engine/tests/funboxes.rs index 1c92933e41..8299c6d4b2 100644 --- a/crates/typing-engine/tests/funboxes.rs +++ b/crates/typing-engine/tests/funboxes.rs @@ -58,3 +58,33 @@ fn all_funbox_pairs_and_forced_configuration_match_frozen_typescript() { ); } } + +#[test] +fn batch_setting_conflicts_preserve_host_field_order_and_original_decisions() { + use typing_engine::policy::{Query, evaluate}; + let values = vec![ + ("mode".to_owned(), serde_json::json!("zen")), + ("theme".to_owned(), serde_json::json!("dark")), + ("words".to_owned(), serde_json::json!(0)), + ]; + for names in [ + vec!["poetry".to_owned()], + vec!["nospace".to_owned()], + vec![], + ] { + let expected: Vec<_> = values + .iter() + .filter(|(key, value)| !typing_engine::funboxes::can_set(key, value, &names).unwrap()) + .map(|(key, _)| key.clone()) + .collect(); + let response = evaluate(Query::SettingConflicts { + names, + values: values.clone(), + }) + .unwrap(); + assert_eq!( + serde_json::to_value(response).unwrap()["keys"], + serde_json::json!(expected) + ); + } +} diff --git a/crates/typing-engine/tests/generated_session.rs b/crates/typing-engine/tests/generated_session.rs index 85201b0f51..2de6270e59 100644 --- a/crates/typing-engine/tests/generated_session.rs +++ b/crates/typing-engine/tests/generated_session.rs @@ -258,3 +258,27 @@ fn timestamp_ties_and_delayed_inputs_signal_sorted_log_invalidation() { assert!(delayed.log_invalidated); assert_eq!(session.log()["events"][0]["testMs"], -10.0); } + +#[test] +fn attached_zen_generation_never_refills_targets_or_changes_headless_input_contract() { + let config = Config { + mode: "zen".into(), + ..Config::default() + }; + let mut prepared = Session::from_generator(generator(config.clone(), None), false).unwrap(); + let mut headless = Session::new(config, vec![]).unwrap(); + for index in 0..150 { + let now = index as f64 * 100.0; + let update = insert(&mut prepared, Text::from("one "), now); + let expected = insert(&mut headless, Text::from("one "), now); + assert_eq!( + serde_json::to_value(update.state).unwrap(), + serde_json::to_value(expected.state).unwrap() + ); + assert!(update.words.is_empty()); + } + assert_eq!(prepared.log(), headless.log()); + assert!(prepared.snapshot().words.is_empty()); + assert_eq!(prepared.state().word_index, 150); + assert!(!prepared.state().ended); +} diff --git a/crates/typing-engine/tests/language_dictionary.rs b/crates/typing-engine/tests/language_dictionary.rs new file mode 100644 index 0000000000..a2bb06cd4e --- /dev/null +++ b/crates/typing-engine/tests/language_dictionary.rs @@ -0,0 +1,27 @@ +use serde_json::Value; +use typing_engine::{Text, language_rules::LanguageRules}; + +#[test] +fn immutable_british_dictionary_has_checked_shapes_and_exact_lowercase_replacements() { + let dictionary: serde_json::Map = + serde_json::from_str(include_str!("../data/british-english.json")).unwrap(); + assert!(dictionary.len() > 500); + let rules = LanguageRules::new().unwrap(); + for (word, rule) in dictionary { + let replacement = if let Some(word) = rule.as_str() { + word + } else { + let exceptions = rule["exceptPreviousWords"].as_array().unwrap(); + assert!(exceptions.iter().all(|word| word.as_str().is_some())); + rule["britishWord"].as_str().unwrap() + }; + assert!(!replacement.is_empty()); + if word == word.to_lowercase() && !word.contains('-') { + assert_eq!( + rules.british(Text::from(word.as_str()), None, false), + Text::from(replacement), + "{word}" + ); + } + } +} diff --git a/crates/typing-engine/tests/live.rs b/crates/typing-engine/tests/live.rs index 3c85c154dc..07dc0ad1d1 100644 --- a/crates/typing-engine/tests/live.rs +++ b/crates/typing-engine/tests/live.rs @@ -15,6 +15,7 @@ fn live_queries_preserve_clock_accuracy_spacing_and_reset_without_log_reads() { .unwrap(); let empty = session.live_stats(100.0).unwrap(); assert_eq!(empty.acc, 100.0); + assert_eq!(empty.time_limit_seconds, Some(0.0)); assert_eq!(empty.wpm, 0.0); assert_eq!(empty.timer_start_ms, None); assert_eq!(empty.ms_since_last_input, None); @@ -47,6 +48,13 @@ fn live_queries_preserve_clock_accuracy_spacing_and_reset_without_log_reads() { assert_eq!(live.chars.correct_word, 4); assert_eq!(live.wpm, 48.0); assert_eq!(live.raw, 48.0); + let log = serde_json::from_value(session.log()).unwrap(); + assert_eq!( + session.word_burst(0, 1100.123).unwrap(), + typing_engine::stats::word_burst(&log, 0, 1000.0) + ); + assert_eq!(session.word_burst(100, 1100.123).unwrap(), 0.0); + assert!(session.word_burst(0, f64::INFINITY).is_err()); session.apply(Command::Finalize { now: 1100.123 }).unwrap(); let after = session.live_stats(1100.123).unwrap(); assert_eq!( @@ -61,3 +69,61 @@ fn live_queries_preserve_clock_accuracy_spacing_and_reset_without_log_reads() { assert_eq!(fresh.timer_start_ms, None); assert_eq!(fresh.wpm, 0.0); } + +#[test] +fn owned_delete_preflight_preserves_confidence_freedom_and_visible_word_guards() { + let mut session = Session::new( + Config { + mode: "words".into(), + words: 0, + ..Config::default() + }, + vec![Text::from("a "), Text::from("b ")], + ) + .unwrap(); + assert!(!session.can_delete()); + session + .apply(command( + serde_json::json!({"op":"insert","data":Text::from("a "),"now":1}), + )) + .unwrap(); + assert!(!session.can_delete()); // completed previous word, freedom off + let config = Config { + mode: "words".into(), + words: 0, + freedom_mode: true, + ..Config::default() + }; + session + .apply(Command::Configure { + now: 2.0, + config: Box::new(config), + }) + .unwrap(); + assert!(session.can_delete()); + session + .apply(command( + serde_json::json!({"op":"view","now":2,"earliestVisibleWord":1}), + )) + .unwrap(); + assert!(!session.can_delete()); + session + .apply(command( + serde_json::json!({"op":"insert","data":Text::from("x"),"now":3}), + )) + .unwrap(); + assert!(session.can_delete()); + let config = Config { + mode: "words".into(), + words: 0, + confidence_mode: "max".into(), + ..Config::default() + }; + session + .apply(Command::Configure { + now: 4.0, + config: Box::new(config), + }) + .unwrap(); + assert!(!session.can_delete()); +} diff --git a/crates/typing-engine/tests/quote_queue.rs b/crates/typing-engine/tests/quote_queue.rs index ed686b2fbc..739d202bab 100644 --- a/crates/typing-engine/tests/quote_queue.rs +++ b/crates/typing-engine/tests/quote_queue.rs @@ -57,3 +57,145 @@ fn explicit_quote_length_changes_match_frozen_owned_queue_and_random_draws() { } } } + +fn web_quote(mut value: Value) -> Value { + if value.is_null() { + return value; + } + for key in ["text", "britishText"] { + if let Some(units) = value.get(key).filter(|value| !value.is_null()) { + let units: Vec = serde_json::from_value(units.clone()).unwrap(); + value[key] = json!(String::from_utf16(&units).unwrap()); + } + } + if value["textSplit"].as_array().is_some_and(Vec::is_empty) { + value.as_object_mut().unwrap().remove("textSplit"); + } else if let Some(parts) = value.get_mut("textSplit").and_then(Value::as_array_mut) { + for part in parts { + let units: Vec = serde_json::from_value(part.clone()).unwrap(); + *part = json!(String::from_utf16(&units).unwrap()); + } + } + value +} +#[test] +fn catalog_reads_match_original_collections_retained_queues_and_rng_exactly() { + let reference: Value = serde_json::from_str(include_str!( + "../../../packages/typing-fixtures/oracle/quote-catalog-v2.json" + )) + .unwrap(); + let mut quotes = typing_engine::quotes::Quotes::default(); + let mut random = Random::seeded(reference["seed"].as_u64().unwrap() as u32); + for action in reference["actions"].as_array().unwrap() { + match action["op"].as_str().unwrap() { + "load" => { + let mut asset = reference["assets"][action["language"].as_str().unwrap()].clone(); + for quote in asset["quotes"].as_array_mut().unwrap() { + quote["text"] = json!(Text::from(quote["text"].as_str().unwrap())); + } + let lengths: Option> = action + .get("lengths") + .map(|value| serde_json::from_value(value.clone()).unwrap()); + let collection = quotes + .load_catalog( + serde_json::from_value(asset).unwrap(), + lengths.as_deref(), + &mut random, + ) + .unwrap(); + let items: Vec = collection + .quotes + .into_iter() + .map(|quote| web_quote(json!(quote))) + .collect(); + let groups: Vec> = collection + .groups + .into_iter() + .map(|group| { + group + .into_iter() + .map(|index| items[index].clone()) + .collect() + }) + .collect(); + assert_eq!( + json!({"quotes":items,"groups":groups,"length":collection.length,"language":collection.language}), + action["expected"] + ); + } + "select" => assert_eq!( + web_quote(json!(quotes.random_quote(&mut random).unwrap())), + action["expected"] + ), + "update" => quotes + .update_queue( + &serde_json::from_value::>(action["lengths"].clone()).unwrap(), + &mut random, + ) + .unwrap(), + _ => panic!("Unexpected oracle action"), + } + assert_eq!(json!(random)["state"], action["randomState"], "{action}"); + } +} + +#[test] +fn selected_quote_normalization_remains_visible_in_the_owned_catalog() { + let reference: Value = serde_json::from_str(include_str!( + "../../../packages/typing-fixtures/oracle/quote-catalog-selection.json" + )) + .unwrap(); + let mut asset = reference["asset"].clone(); + for quote in asset["quotes"].as_array_mut().unwrap() { + for key in ["text", "britishText"] { + quote[key] = json!(Text::from(quote[key].as_str().unwrap())); + } + } + let mut quotes = typing_engine::quotes::Quotes::default(); + let mut random = Random::seeded(11); + quotes + .load(serde_json::from_value(asset).unwrap(), &[-2], &mut random) + .unwrap(); + for stage in reference["stages"].as_array().unwrap() { + let selected = quotes + .select( + "swiss_german", + &[-2], + 1, + &[], + stage["british"].as_bool().unwrap(), + &mut random, + ) + .unwrap(); + assert_eq!( + web_quote(json!(&selected)), + stage["expected"]["currentQuote"] + ); + let words: Vec<_> = selected + .text_split + .iter() + .map(|text| String::from_utf16(&text.0).unwrap()) + .collect(); + assert_eq!(json!(words), stage["expected"]["words"]); + let collection = quotes.collection(); + let items: Vec = collection + .quotes + .into_iter() + .map(|quote| web_quote(json!(quote))) + .collect(); + let groups: Vec> = collection + .groups + .into_iter() + .map(|group| { + group + .into_iter() + .map(|index| items[index].clone()) + .collect() + }) + .collect(); + assert_eq!( + json!({"quotes":items,"groups":groups,"length":collection.length,"language":collection.language}), + stage["expected"]["collection"] + ); + } +} diff --git a/crates/typing-engine/tests/restart.rs b/crates/typing-engine/tests/restart.rs index c04f46262f..e41421f847 100644 --- a/crates/typing-engine/tests/restart.rs +++ b/crates/typing-engine/tests/restart.rs @@ -12,6 +12,13 @@ fn long_test_thresholds_match_frozen_typescript_at_every_boundary() { for case in reference["quick"].as_array().unwrap() { let config: Config = serde_json::from_value(case.clone()).unwrap(); let limit = serde_json::from_value(case["limit"].clone()).unwrap(); + let policy = typing_engine::policy::evaluate(serde_json::from_value(json!({ + "op":"quickRestart", "config":case, "limit":case["limit"], "customIsLong":case["customIsLong"] + })).unwrap()).unwrap(); + assert_eq!( + serde_json::to_value(policy).unwrap()["result"], + case["expected"] + ); assert_eq!( restart::can_quick_restart( &config, diff --git a/crates/typing-engine/tests/string_rules.rs b/crates/typing-engine/tests/string_rules.rs new file mode 100644 index 0000000000..c09cdc60c7 --- /dev/null +++ b/crates/typing-engine/tests/string_rules.rs @@ -0,0 +1,36 @@ +use serde_json::{Value, json}; +use typing_engine::{Text, stats, text}; + +#[test] +fn original_browser_string_rule_cases_match_native_utf16_rules() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../packages/typing-fixtures/oracle/string-rules.json"); + let cases: Vec = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + assert_eq!(cases.len(), 68); + for (index, case) in cases.iter().enumerate() { + let args = &case["args"]; + let input = Text::from(args[0].as_str().unwrap()); + let actual = match case["name"].as_str().unwrap() { + "countChars" => json!(stats::count_chars( + &input, + &Text::from(args[1].as_str().unwrap()), + args[2].as_bool().unwrap() + )), + "isSpace" => json!(input.len() == 1 && text::is_space(input.0[0])), + "areCharactersVisuallyEqual" => { + let target = Text::from(args[1].as_str().unwrap()); + assert_eq!(input.len(), 1); + assert_eq!(target.len(), 1); + json!( + text::normalize( + input.0[0], + Some(target.0[0]), + args[2].as_str().unwrap_or("english") + ) == target.0[0] + ) + } + name => panic!("Unmapped captured rule: {name}"), + }; + assert_eq!(actual, case["expected"], "case {index}: {}", case["name"]); + } +} diff --git a/crates/typing-engine/tests/synthetic_analysis.rs b/crates/typing-engine/tests/synthetic_analysis.rs new file mode 100644 index 0000000000..162855f2be --- /dev/null +++ b/crates/typing-engine/tests/synthetic_analysis.rs @@ -0,0 +1,86 @@ +use serde_json::{Value, json}; +use typing_engine::{ + Text, + stats::{self, Log}, +}; + +fn equal(actual: &Value, expected: &Value, path: &str) { + match (actual, expected) { + (Value::Number(a), Value::Number(b)) => assert_eq!(a.as_f64(), b.as_f64(), "{path}"), + (Value::Array(a), Value::Array(b)) => { + assert_eq!(a.len(), b.len(), "{path}"); + for (index, (a, b)) in a.iter().zip(b).enumerate() { + equal(a, b, &format!("{path}[{index}]")); + } + } + (Value::Object(a), Value::Object(b)) => { + assert_eq!(a.len(), b.len(), "{path}"); + for (key, value) in a { + equal(value, &b[key], &format!("{path}.{key}")); + } + } + _ => assert_eq!(actual, expected, "{path}"), + } +} + +#[test] +fn synthetic_legacy_analysis_calls_match_the_write_once_reference() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../packages/typing-fixtures/oracle/synthetic-analysis.json"); + let cases: Vec = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + assert_eq!(cases.len(), 67); + for (index, case) in cases.iter().enumerate() { + let name = case["name"].as_str().unwrap(); + let args = &case["args"]; + let mut wire = args[0].clone(); + wire["context"]["targetWords"] = json!( + wire["context"]["targetWords"] + .as_array() + .unwrap() + .iter() + .map(|word| Text::from(word.as_str().unwrap())) + .collect::>() + ); + let log: Log = serde_json::from_value(wire).unwrap(); + let summary = stats::summary(&log); + let strings = |words: Vec| { + json!( + words + .into_iter() + .map(|word| String::from_utf16(&word.0).unwrap()) + .collect::>() + ) + }; + let actual = match name { + "getChars" => json!(stats::chars( + &log, + args[1].as_bool().unwrap_or(false), + args[2].as_f64() + )), + "getAccuracy" => json!(stats::accuracy_counts(&log, args[1].as_f64())), + "getStartToFirstKeypressMs" => summary["startToFirstKey"].clone(), + "getLastKeypressToEndMs" => summary["lastKeyToEnd"].clone(), + "getTestDurationMs" => json!(stats::duration(&log)), + "getAfkDuration" => json!(stats::afk(&log)), + "getIncompleteTestSeconds" => json!(stats::incomplete_seconds(&log)), + "getKeypressSpacing" => json!(stats::key_timing(&log).spacing), + "getKeypressDurations" => json!(stats::key_timing(&log).durations), + "getKeypressOverlap" => summary["keyOverlap"].clone(), + "getKeypressesPerSecond" => json!(stats::keypresses_per_second(&log)), + "getBurstHistory" => json!(stats::burst_history(&log)), + "getErrorCountHistory" => summary["chartData"]["err"].clone(), + "getWpmHistory" => json!(stats::speed_history(&log, false)), + "getInputHistory" => strings(stats::input_history(&log)), + "getCorrectedWordsHistory" => strings(stats::corrected_history(&log)), + "getMissedWords" => json!( + stats::missed_words(&log) + .into_iter() + .map(|(word, count)| (String::from_utf16(&word.0).unwrap(), count)) + .collect::>() + ), + _ => panic!("Unmapped captured operation: {name}"), + }; + // Compare numeric JSON semantically, preserving exact f64 values. + equal(&actual, &case["expected"], &format!("case {index}: {name}")); + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 540cbcd339..63eddd2604 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -11,14 +11,18 @@ Package versions come from the workspace manifests and lockfile. | `packages/contracts/` | Retained external HTTP API contracts | | `packages/schemas/` | Shared Zod schemas and domain types | | `packages/util/`, `packages/funbox/`, `packages/challenges/` | Shared helpers and typing features | -| `packages/typing-core/` | Headless typing test logic | +| `crates/typing-engine/` | Platform-independent typing rules, generation, scoring and replay | +| `crates/typing-engine-wasm/`, `packages/typing-engine/` | Browser-only Wasm boundary, generated types and owned handles | +| `packages/typing-fixtures/` | Immutable original behavior references and capture provenance | +| `packages/web-shared/` | SSR-safe preferences, browser wire types, transport and display helpers | | `packages/oxlint-config/`, `packages/typescript-config/` | Shared tooling configuration | | `packages/release/` | Production CI validation helpers | | `.github/workflows/` | [CI](CI.md), automatic production deployments after successful main CI, manual retries, labeling, and repository automation | The root is a private pnpm workspace. Vite+ (`vp run`) runs workspace tasks. -Internal packages use the `@oxytype` scope and export TypeScript source; Vite, -Vitest, wrangler and tsx compile it, so packages have no build step. +TypeScript packages use the `@oxytype` scope and export source. Vite, Vitest, +wrangler and tsx compile it. Rust/Wasm and declarations build automatically before +development, production, tests and typechecks; see the [engine SDK](../packages/typing-engine/README.md). ## Application diff --git a/docs/rust-engine/decisions.md b/docs/rust-engine/decisions.md index 95dac10581..4cb2c64fa1 100644 --- a/docs/rust-engine/decisions.md +++ b/docs/rust-engine/decisions.md @@ -38,3 +38,79 @@ Archived native/browser measurements compare incremental object conversion and J ### Fractional-limit capture harness correction Before the fractional Rust port, the first new capture accidentally preloaded the generator through the shared oracle's units helper, pinning the host RNG before seeding. It also supplied a constant zero host word count. Preserve that diagnostic unchanged at diagnostics/fractional-capture-before-harness-fix.json; it is not a parity reference. The corrected standalone capture defines UTF-16 conversion locally, imports the original generator after seeding, and tracks returned host words. Its new write-once reference is fractional-limits-v2.json. No existing reference/snapshot was regenerated, and this harness correction precedes the engine implementation. + +### Browser final-word batch observation + +Before connecting the Rust loop, the existing built TypeScript website recorded +`one extra` against the finite custom target `one` (normal difficulty). Capture: +`frontend/e2e/engine-input-observations.migration.ts`; immutable output: +`packages/typing-fixtures/oracle/browser-final-word-batch.json`. All nine input +observations share the original batch time. Committing clears the textarea even +when the last target cannot advance; the next event retains the prior log's +character index but records the freshly cleared DOM input. Rust now owns this +distinction and exposes an optional empty `inputDisplayValue` projection. State +and event-log input remain unchanged for existing fixtures. No reference updated. + +Browser batches are split only to observe layout between UTF-16 units. Target +normalization/expansion come from native preflight, using the same expansion rule +as native insertion; the browser provides `finalInBatch` for completion policy. + +### Catalog reads share native quote ownership + +Quote search/report previously shared the generator's catalog/queue object. The +new raw asset host delegates catalog grouping and cache mutation to the same Rust +content resource. A catalog read with no explicit length selection preserves an +already-built queue, including old-language quote objects. Selecting a quote +normalizes its stored text/language/textSplit, which remains visible to catalog +readers. Preserve both effects; separate UI selectors must not reseed a queue. + +Original TypeScript captures are write-once `quote-catalog-v2.json` (catalog/queue +ordering and RNG state) and `quote-catalog-selection.json` (selected-object +normalization). The first catalog capture imported pinned RNG helpers before +installing its deterministic stream; retain its bytes as +`diagnostics/quote-catalog-unseeded.json`, classify it as a diagnostic, and use the +separately named corrected capture. No fixture regenerated for a port mismatch. + +The binding sends catalog reads through the existing prepared-asset envelope; +only quotes/random/quote lengths participate. Catalog groups are native indices +into returned quotes; the UI mechanically projects these into object arrays. + +### Binding optimization after catalog coverage + +The unoptimized size boundary correctly rejected catalog additions at 308,493 +bytes gzip6 (and later 307,784 after serializer sharing). Frozen 307,200-byte budget +unchanged. Pinned Binaryen `-Oz --converge` reaches a smaller fixed point; an +isolated candidate measured 772,919 raw / 305,790 gzip6. Build/cache reuse still +checks the original limits. Current-head browser parity and recorded final website +performance remain required; the optimizer experiment alone is not that evidence. + +The proof route skips app policy preloading so cold initialization probes remain +cold. Normal browser routes preload the app-owned engine concurrently with local +preferences/themes; engine-dependent funbox controls wait for policy readiness. + +### SSR-safe web helpers and policy readiness + +Extract settings migration, themes, preference metadata/order to `web-shared`. +Engine defaults are generated Rust fields; preserve web default field enumeration +order because imports apply dependent preferences in that order. Browser funbox +eligibility calls native policies; no browser initializer enters server imports. +Full constrained config application waits for Rust before mutating settings. +Failed local startup preserves the saved config for Restart and does not persist +partial defaults. Empty selections have no constraints and permit theme/preference +bootstrap independently. Tests retain existing cold-import timeout; limit local +concurrent workers when CPU-heavy production builds compete for the same host. + +### Native historical inspection and display-only non-finite bursts + +Expose inspection separately from completed scoring so partial logs preserve +unreleased zero-duration placeholders and original inspector DOM snapshots without +requiring a completed timer. Inspector word position is the original last input +position; session/replay Frame active position retains its separate contract. +Factor existing native timing/accuracy scans; no scoring formulas change. + +Per-word burst can be positive infinity in the original engine. serde_json emits +null; generated types declare that wire representation explicitly, and the host +restores Infinity only for presentation. Completed metrics retain strict finite +validation. Do not allocate an intermediate Option array solely for JSON conversion. +Historical capture sources that refer to removed functions are archived as inert +text; parity CI runs directly against immutable outputs without the old engine. diff --git a/docs/rust-engine/diagnostics/quote-catalog-unseeded.json b/docs/rust-engine/diagnostics/quote-catalog-unseeded.json new file mode 100644 index 0000000000..2860900418 --- /dev/null +++ b/docs/rust-engine/diagnostics/quote-catalog-unseeded.json @@ -0,0 +1,412 @@ +{ + "seed": 19, + "assets": { + "english": { + "language": "english", + "groups": [ + [0, 10], + [11, 100], + [101, 200], + [201, 999] + ], + "quotes": [ + { + "id": 1, + "text": "english short", + "length": 5, + "source": "fixture" + }, + { + "id": 2, + "text": "english first long", + "length": 16, + "source": "fixture" + }, + { + "id": 3, + "text": "english second long", + "length": 17, + "source": "fixture" + } + ] + }, + "german": { + "language": "german", + "groups": [ + [0, 10], + [11, 100], + [101, 200], + [201, 999] + ], + "quotes": [ + { + "id": 11, + "text": "german short", + "length": 5, + "source": "fixture" + }, + { + "id": 12, + "text": "german first long", + "length": 16, + "source": "fixture" + }, + { + "id": 13, + "text": "german second long", + "length": 17, + "source": "fixture" + } + ] + } + }, + "actions": [ + { + "op": "load", + "language": "english", + "lengths": [1], + "expected": { + "quotes": [ + { + "text": "english short", + "source": "fixture", + "length": 5, + "id": 1, + "language": "english", + "group": 0 + }, + { + "text": "english first long", + "source": "fixture", + "length": 16, + "id": 2, + "language": "english", + "group": 1 + }, + { + "text": "english second long", + "source": "fixture", + "length": 17, + "id": 3, + "language": "english", + "group": 1 + } + ], + "length": 3, + "groups": [ + [ + { + "text": "english short", + "source": "fixture", + "length": 5, + "id": 1, + "language": "english", + "group": 0 + } + ], + [ + { + "text": "english first long", + "source": "fixture", + "length": 16, + "id": 2, + "language": "english", + "group": 1 + }, + { + "text": "english second long", + "source": "fixture", + "length": 17, + "id": 3, + "language": "english", + "group": 1 + } + ], + [], + [] + ], + "language": "english" + }, + "randomState": 19 + }, + { + "op": "select", + "expected": { + "text": "english first long", + "source": "fixture", + "length": 16, + "id": 2, + "language": "english", + "group": 1 + }, + "randomState": 19 + }, + { + "op": "load", + "language": "german", + "expected": { + "quotes": [ + { + "text": "german short", + "source": "fixture", + "length": 5, + "id": 11, + "language": "german", + "group": 0 + }, + { + "text": "german first long", + "source": "fixture", + "length": 16, + "id": 12, + "language": "german", + "group": 1 + }, + { + "text": "german second long", + "source": "fixture", + "length": 17, + "id": 13, + "language": "german", + "group": 1 + } + ], + "length": 3, + "groups": [ + [ + { + "text": "german short", + "source": "fixture", + "length": 5, + "id": 11, + "language": "german", + "group": 0 + } + ], + [ + { + "text": "german first long", + "source": "fixture", + "length": 16, + "id": 12, + "language": "german", + "group": 1 + }, + { + "text": "german second long", + "source": "fixture", + "length": 17, + "id": 13, + "language": "german", + "group": 1 + } + ], + [], + [] + ], + "language": "german" + }, + "randomState": 19 + }, + { + "op": "select", + "expected": { + "text": "english second long", + "source": "fixture", + "length": 17, + "id": 3, + "language": "english", + "group": 1 + }, + "randomState": 19 + }, + { + "op": "select", + "expected": { + "text": "english first long", + "source": "fixture", + "length": 16, + "id": 2, + "language": "english", + "group": 1 + }, + "randomState": 19 + }, + { + "op": "update", + "lengths": [1], + "randomState": 19 + }, + { + "op": "select", + "expected": { + "text": "german second long", + "source": "fixture", + "length": 17, + "id": 13, + "language": "german", + "group": 1 + }, + "randomState": 19 + }, + { + "op": "load", + "language": "english", + "expected": { + "quotes": [ + { + "text": "english short", + "source": "fixture", + "length": 5, + "id": 1, + "language": "english", + "group": 0 + }, + { + "text": "english first long", + "source": "fixture", + "length": 16, + "id": 2, + "language": "english", + "group": 1 + }, + { + "text": "english second long", + "source": "fixture", + "length": 17, + "id": 3, + "language": "english", + "group": 1 + } + ], + "length": 3, + "groups": [ + [ + { + "text": "english short", + "source": "fixture", + "length": 5, + "id": 1, + "language": "english", + "group": 0 + } + ], + [ + { + "text": "english first long", + "source": "fixture", + "length": 16, + "id": 2, + "language": "english", + "group": 1 + }, + { + "text": "english second long", + "source": "fixture", + "length": 17, + "id": 3, + "language": "english", + "group": 1 + } + ], + [], + [] + ], + "language": "english" + }, + "randomState": 19 + }, + { + "op": "select", + "expected": { + "text": "german first long", + "source": "fixture", + "length": 16, + "id": 12, + "language": "german", + "group": 1 + }, + "randomState": 19 + }, + { + "op": "load", + "language": "english", + "lengths": [0], + "expected": { + "quotes": [ + { + "text": "english short", + "source": "fixture", + "length": 5, + "id": 1, + "language": "english", + "group": 0 + }, + { + "text": "english first long", + "source": "fixture", + "length": 16, + "id": 2, + "language": "english", + "group": 1 + }, + { + "text": "english second long", + "source": "fixture", + "length": 17, + "id": 3, + "language": "english", + "group": 1 + } + ], + "length": 3, + "groups": [ + [ + { + "text": "english short", + "source": "fixture", + "length": 5, + "id": 1, + "language": "english", + "group": 0 + } + ], + [ + { + "text": "english first long", + "source": "fixture", + "length": 16, + "id": 2, + "language": "english", + "group": 1 + }, + { + "text": "english second long", + "source": "fixture", + "length": 17, + "id": 3, + "language": "english", + "group": 1 + } + ], + [], + [] + ], + "language": "english" + }, + "randomState": 19 + }, + { + "op": "select", + "expected": { + "text": "german second long", + "source": "fixture", + "length": 17, + "id": 13, + "language": "german", + "group": 1 + }, + "randomState": 19 + } + ] +} diff --git a/docs/rust-engine/diagnostics/website-retired-readiness-before-preload.json b/docs/rust-engine/diagnostics/website-retired-readiness-before-preload.json new file mode 100644 index 0000000000..aedf4d642b --- /dev/null +++ b/docs/rust-engine/diagnostics/website-retired-readiness-before-preload.json @@ -0,0 +1,18 @@ +{ + "fixture": "production assets, guest API fixtures, desktop Chromium, HTTP cache cold/warm, SW bypass", + "cold": [ + 625.9000000059605, 1221.800000011921, 1153.8999999761581, + 1192.7000000178814, 1023, 1078.2999999821186, 1110.9000000059605, + 1060.2999999821186, 569.2000000178814, 1171.0999999940395 + ], + "warm": [ + 575.5, 605.5999999940395, 577.6999999880791, 592.5999999940395, 581.5, + 551.3000000119209, 539.0999999940395, 584.5999999940395, 650.5999999940395, + 586.9000000059605 + ], + "settings": [ + 546.2000000178814, 500.59999999403954, 533.8000000119209, 532.0999999940395, + 574.4000000059605, 554.5999999940395, 532.2999999821186, 541.5, + 530.8000000119209, 553.8000000119209 + ] +} diff --git a/docs/rust-engine/features.md b/docs/rust-engine/features.md index d85a3040cf..ff1734e145 100644 --- a/docs/rust-engine/features.md +++ b/docs/rust-engine/features.md @@ -86,3 +86,26 @@ Rust parity is complete for the shared-rule rows below. Website integration rema | `asl` | Host visual/audio/layout; Rust retains metadata effects on eligibility/config | Native/Wasm metadata + forced/compatibility policies pass; rule transforms in generation oracle; host hooks Stage 2 | | `rot13` | Rust rules; retain any visual/input hooks in host | Native/Wasm metadata + forced/compatibility policies pass; rule transforms in generation oracle; host hooks Stage 2 | | `no_quit` | Rust rules; retain any visual/input hooks in host | Native/Wasm metadata + forced/compatibility policies pass; rule transforms in generation oracle; host hooks Stage 2 | + +## Website generation/support gate (Phase 2.3) + +All modes and rule-bearing funboxes now use native BrowserContent/BrowserSession. +The frontend registry retains visual/audio/platform hooks only. Native policies +serve settings compatibility/forced configuration and restart eligibility. Practice, +learning and pace decisions use native queries. Quote modal/catalog shares native +queue storage and selected-object normalization with test generation. All 61 +original generator snapshots pass through the actual browser binding/asset adapter. +Built-site source tests cover delayed batches, cancellation, source failure and +retained original event timing; settings imports/commands/quotes/restarts pass the +broader existing browser suite. Results/replay consumers remain Phase 2.4 work. + +## Website final ownership (Phase 2.6) + +All advertised website modes/funboxes use Rust. Input/IME behavior, timer completion, +settings/quote/practice/repeat policies, results and historical inspection/playback +use native decisions. Hosts retain DOM/layout observations, rendering/audio, I/O, +hashing, storage/account transport and presentation. The TypeScript engine package +and unused rule facades are removed; non-engine helpers live in `web-shared`. +Historical references and UTF-16 transport remain unchanged. No redesign or scoring +change included. Cloudflare build/delivery and rollback retain matched Wasm/glue. +Hosted release validation still follows the existing deployment authorization. diff --git a/docs/rust-engine/inventory.md b/docs/rust-engine/inventory.md index 2ce06c801a..a4119bd2fe 100644 --- a/docs/rust-engine/inventory.md +++ b/docs/rust-engine/inventory.md @@ -275,3 +275,60 @@ Rule hooks move to Rust; visual/audio effects, fetch and notifications stay host SHA-256 of all current fixtures: `reference-manifest.json`. Never regenerate snapshots/hashes to make a port pass. Existing backend anticheat consumers retained. + +## Website destinations after Phase 2.3 + +| Consumer | Implemented destination | +| --- | --- | +| Browser input/deletion/composition/timing | `runtime/session.client.ts` → owned native BrowserSession; `input-projection.client.ts` renders deltas | +| Words/sections/custom/repeat/quote queue/catalog | `runtime/content.client.ts` → native BrowserContent; raw assets in `content-assets.client.ts` | +| Funbox rule hooks | Rust generator/transform/policy modules; browser registry contains visual/audio/platform hooks | +| Funbox eligibility/forced config | `policy.client.ts` → native policy queries | +| Restart/no quit/long-test/pace decisions | native session restart policy; web orchestrates animations/storage | +| Practice/weak-spot learning | native analysis/content owner; web retains practice UI/preferences | +| Defaults/themes/config metadata/migration/import order | `packages/web-shared/src` (SSR safe); engine defaults from Rust artifacts; browser injects native funbox policies | +| Completion/history/chart/replay playback | Phase 2.4 active; original consumers listed above remain until migrated | +| Remaining generic TS helpers/types | Phase 2.6 extraction/removal tracked; never keep legacy rule implementations as helpers | + +Evidence: all 61 frozen frontend generators run actual optimized Wasm; 54 broad +built-site settings/command/modal/quote/restart checks; source/loading edge checks; +1,577 frontend units and six workerd SSR document cases. Historical consumer map +above records the original audit, not current imports. + +## Website result destinations after Phase 2.4 + +- `features/typing/analysis.client.ts`: owned native result/inspector queries and + mechanical UTF-16/infinite-burst projections; WeakMap caches immutable views. +- `runtime/completion.client.ts`: Rust validity/AFK/custom-progress decisions; + existing account metadata, tags/challenges, storage/hash/submission preserved. +- Result history/chart/accuracy components and command palette consume that facade. +- `runtime/replay.ts`: native BrowserReplay mutates/seeks; route-owned timers/audio + and existing result rendering retained. `replay-view.ts` contains DTO view types. +- Event-log viewer input/position/timing derives from native inspection; malformed + loads fail in its existing error UI. Timeline/video/chart presentation stays TS. +- Old inspector reconstruction removed; its capture source preserved inertly under + `typing-fixtures/provenance`. Remaining legacy engine utilities retire in 2.6. + +## Final website consumer disposition (Phase 2.6) + +The earlier inventory is an immutable audit of the original source, not current +imports. `@oxytype/typing-core` and all its implementations are removed. + +- Rust: normalization/acceptance/deletion/IME decisions; session/timer/fail/finish; + generation/transforms/catalog/learning/pace; score/validity/AFK/progress; inspection + and replay. All active browser rule facades call the owned native handles. +- `web-shared`: wire event types and DOM input-type classification, settings migration/ + metadata, command matching, physical layout translation, fetch/cache/integrity + helpers, custom-text initial editor content, result hash/field validation, display + units/formatting/progress labels and account daily totals. No session progression, + target generation, score derivation or replay mutation. +- Removed browser facades: old input validation/util/fail-or-finish, poetry/Wikipedia + normalizers, weak-spot, British and IP generation. Test-only legacy recorder removed. +- Backend independent validation remains TypeScript (including funbox compatibility); + no validation weakened. Backend fixture readers now use `typing-fixtures/reference`. +- Generic host tests moved to `web-shared`; 67 original synthetic analysis calls and + 68 string-rule cases captured once and checked natively. Original 100/157-case + suites passed during capture. Existing 19 command traces, 61 generator snapshots, + 8 results/hashes and 1,387 live prefixes stay frozen. +- Capture tools archived as inert provenance. The historical source and other old + unit suites remain retrievable at `1f69247bf`; no maintained TS engine needed. diff --git a/docs/rust-engine/progress.md b/docs/rust-engine/progress.md index dcbb93e4eb..ee71a14a43 100644 --- a/docs/rust-engine/progress.md +++ b/docs/rust-engine/progress.md @@ -12,7 +12,7 @@ empty commits to inflate count. Do not publish production or releases implicitly ## Current position -Stage 1 engine gate passed. Stage 2 website integration next; Stage 3 remains pending. Read root AGENTS.md and the complete migration +Stage 1 and Stage 2 gates passed. Stage 3 begins next on a branch stacked above `stack/rust-engine-website`. Read root AGENTS.md and the complete migration plan before edits. Installed workspace dependencies with frozen lockfile. Phase 1.1 and 1.2 gates passed: inventory/baselines, lossless contract, frozen oracle and mismatch detection. Existing website engine remains authoritative until Stage 2. @@ -25,7 +25,12 @@ Phase 1.1 and 1.2 gates passed: inventory/baselines, lossless contract, frozen o | 1.5 Generation / configuration | Complete | 61 original snapshots; section/fractional/live generation; all funbox policies; quote refresh RNG; typed recovery and bounded malformed content. | | 1.6 Events / results / replay | Complete | Eight exact original CompletedEvents/hashes/schema/backend outcomes, replay/seek, Unicode history, 1,387 live prefixes and malformed-log validation. | | 1.7 Binding / engine gate | Complete | 24 built-browser cases, unchanged results/hashes, frozen payload/performance limits, fresh production build/typechecks; CI engine/native/Wasm and quality/package/SSR/delivery/update jobs pass at e7b7ad864. Existing website browser shards still running when this record was written. | -| 2.1–2.6 Website | Pending | Requires Stage 1 gate. | +| 2.1 Browser owner / loading | Complete | App-owned loader/content/session resource; 51 unit cases; actual Cloudflare delayed-download/503/retry browser checks pass; server bundles contain no Wasm initializer. | +| 2.2 Input / timer projection | Complete | Native website loop; 1,575 unit cases, 19 built typing parity cases and 8 loading/input/IME/commit browser checks pass. | +| 2.3 Generation / settings consumers | Complete | Native generation/catalog/settings/restart/practice; source cancellation/recovery; SSR-safe settings extraction; 1,577 unit tests and built-browser gates pass. | +| 2.4 Results / replay | Complete | Native validity/progress/history/charts/inspector/playback; unit/JSX, frozen parity, save retry and real Start/D1 persistence pass. | +| 2.5 Delivery / performance | Complete | Fresh Cloudflare build/preview/types, 86 Start checks, update/rollback, full website and controlled Wasm budgets pass. | +| 2.6 TypeScript retirement | Complete | Legacy package removed; full native/Wasm/website/quality CI, fresh build/types and frozen performance gates pass. | | 3.1–3.5 Terminal | Pending | Requires Stage 2 gate. | ## Verification log @@ -240,3 +245,291 @@ do not rerun completed checks without a new reason. Engine gate passed at e7b7ad864 plus automated payload checks ee676ab63. Current API behavior/native/Wasm parity, bounded failures/lifecycles, exact legacy result transport, frozen performance limits and isolated fresh-checkout build/typechecks verified. All original references remain unchanged. Full CI 39932135295 has passed engine, package/frontend/backend, quality, SSR, delivery, assets and update jobs; four existing website browser shards were still running at this handoff. Track their actual final outcome without claiming it early. Stage 2 begins on a child branch/PR based on the Stage 1 branch. First work: browser-only app resource, loading/retry/disposal and configuration projection, then replace the typing loop and asset/result/replay consumers. The production website still uses TypeScript until integration passes. No production deployment or release publication performed. Stage 3 remains entirely pending. + +- Stage 2: added browser-only app resource with lazy owned loader, explicit loading/ready/failed/disposed status, retry, session replacement generations and retained content learning. App disposal aborts loading and releases active session/content; stale initialization cannot publish. Three focused ownership/lifecycle tests and type-aware lint pass. Production typing loop still uses TypeScript at this incremental checkpoint. + +- Phase 2.1 route startup now waits for usable Wasm, disables textarea during loading/restart/failure, uses the existing initialization-error UI and Restart retry, and releases sessions on route exit. Cloudflare production build and 51 lifecycle/unit cases pass; actual website delayed-download and 503/Restart cases pass (2 tests, 6.8 s). Server JS scan finds no engine_bg/wasm_bindgen/WebAssembly/binding initializer. These website cases are included in the shared browser CI task. No typing commands have switched engines yet; Phase 2.2 replaces the loop as a whole. + +- Added SSR-safe normalized config projection from generated Rust defaults/field names. It copies mutable arrays, excludes web presentation fields and leaves validation to Rust. Focused projection test and type-aware lint pass; no duplicate default/rule table introduced. + +- Stage 1 PR #184 at dc843955a now has every CI check green, including all four website browser shards and ci-ok; marked ready for review. Stage 2 remains draft #185, stacked on #184, with force-full-ci enabled. No production deployment/publication. + +- Phase 2.2 supporting API: native deletion preflight reuses the same mutation guard; exposes cached word burst and shared time-limit metadata without full-log reads. Rust/native suite and Clippy pass; query values, metadata and startup/retry pass all 26 actual Cloudflare browser cases (54.3 s). Wasm 756,381 raw / 302,064 gzip6 bytes remains within frozen budget. Owner leases now abort/invalidate pending session work on replacement/route exit. Web event conversion retains generated discriminated types and lossless text. + +- Added the app-owned Rust session projection/controller: commands retain captured clocks, configuration is projected before native reads, physical/composition observations convert losslessly, input snapshots follow deltas without a growing JS event log, and native finish requests notify once before acknowledgement at the host completion clock. Host timer scheduling and pace rendering use native outputs. Four focused projection tests pass (UTF-16, no full-log hot reads, deferred finish, stale replacement, Firefox residue); owner/text regression group previously passed nine checks. Controller is not yet wired into production typing handlers; full Phase 2.2 gate remains open. + +- Before replacing external-source loading, captured the original Wikipedia empty-extract retry lifecycle once (wikipedia-retry.json): English cleanup makes the first raw extract empty; the original fetches another article and returns its exact words. Capture refuses overwrite and typechecks. Raw source adapters must retain this I/O retry without reimplementing text cleanup in TypeScript. Non-overlapping content preparation is proceeding under Phase 2.3 while the new input loop is connected. + +- Empty source cleanup now returns structured emptyContent while preserving the single pending token. Native test matches the write-once original Wikipedia retry/output reference. Full native suite/Clippy pass. No reference changed; the host retries only Wikipedia emptiness and retains cancellation/source-failure behavior. + +- Native IME quick-finish query now compares owned finite targets/input in exact UTF-16; ended/infinite/pending sessions reject it. Small metadata deltas report generation flag changes without exporting targets. Native query regressions, full native suite and Clippy pass. All 26 built Cloudflare browser cases pass (55.2 s), including surrogate composition and metadata reads. Wasm 757,683 raw / 302,634 gzip6 bytes remains within budget. Owner invalidation now aborts source/automatic continuations on reset/end while retaining the native handle for result reads. Timer/explicit failure acknowledgement retains its captured clock; input completion retains the later observed browser clock. Focused transport/content/owner suite passes 15 checks. + +- Added the browser content host: converts raw language/quote/custom assets into lossless native DTOs; loads account favorite IDs/polyglot assets; delegates prepare/repeat/selection/generation entirely to Rust; supplies one requested raw source at a time. Empty Wikipedia cleanup retries the exact token; active requests share one promise and abort on invalidation. Quote queue refresh also works after failed selection, and raw cached-language data remains reusable. Six initial asset/content checks plus shared-request regression pass; combined owner/projection/content suite passes 15 checks. This host is not yet used by the production typing loop. + +## Website typing-loop cutover checkpoint + +- Native commands now drive the website input/deletion/IME loop. Browser handlers + supply UTF-16 units, actual textarea deletion and measured layout/visibility; + Rust owns normalization/expansion, acceptance, navigation, automatic indentation, + finish/failure, recorder cleanup, live statistics, timing and pace correction. +- Component-owned subscription projects deltas into existing word/caret rendering, + visual/audio hooks and loading UI. Initial and incremental content use the owned + raw-source adapter, with generation cancellation and exact source-token reuse. +- Fixed two integration defects found by broad browser checks: generated content + must transfer into its session without another generation/RNG/source pass; + omitted quote `britishText` must be optional in generated declarations. Existing + restart focus is preserved while loading/error gates still disable unavailable input. +- Added immutable original-browser `browser-final-word-batch.json`; native test + matches all nine entries and the actual migrated website matches it exactly. + No snapshot regenerated. Composition and blind commit ordering checks added. +- `vp run engine:test`: full native pass after content handoff; Clippy passes. +- Actual Cloudflare build + migration suite: 27 pass (54.2 s). Subsequent built-site + typing parity: 19 pass (1.0 m), including external poetry/Wikipedia in two + languages, quotes, restart focus, words/time and signed-in result submission. +- `vp test run --project=unit-frontend`: 132 files / 1,575 tests pass after adapting + lifecycle, input, pace, app ownership and replay derivation to actual optimized + Wasm. Frozen replay snapshots remain byte-for-byte unchanged. Latest visual + ordering/metadata changes pass the subsequent 8-case built-browser rerun (15.0 s), + including synthetic composition quick finish and blind-mode commit rendering. +- Whole frontend type-aware lint/typecheck passes. Native source generation and + all frontend validation evidence above are local; current PR CI remains pending. +- Transitional test-only `__harness__/legacy-event-recorder.ts` isolates deliberately + synthetic legacy recorder/statistics cases. It is not imported by production. + Retire it with the old core in Phase 2.6 after moving those cases to neutral/native + analysis fixtures. This is outstanding migration work, not final architecture. +- Next: finish browser input/source edge checks; migrate remaining settings/restart/ + practice policies and result/history/replay playback; delivery/performance and + TypeScript retirement remain open. Terminal Stage 3 remains pending. + +- Phase 2.2 gate passed at `af262a763`: typing-loop replacement committed. Subsequent + eight-case browser edge gate also passes; continue immediately with Phase 2.3. + Actual hardware IME/OS keyboard matrix is not represented by synthetic events. + +## Phase 2.3 active checkpoint — settings and catalogs + +- Native stateless quick-restart and batched setting-conflict queries committed + (`efb5964fe`); catalog ownership/binding committed (`1c7d6ac75`). Native full + tests and Clippy pass, including all immutable generator/result references and + new catalog queue/normalization captures. Diagnostic capture retained separately. +- Pinned Binaryen convergence committed (`a669b6f43`); current generated payload + 773,252 raw / 305,988 gzip6 bytes. Original payload limits unchanged. +- Current working tree connects native policies to settings, funbox compatibility, + no-quit/navigation/restart, default projection and practice. Normal browser routes + preload policies alongside preferences; proof route skips this preload. +- Word generation facade removed from active frontend; all 61 frontend generator + cases now call the actual optimized Wasm/asset conversion and compare unchanged + independent snapshots. Funbox hook registry contains host visual/audio/input + hooks only; grouping/quote queue/catalog mutation now share Rust ownership. +- Current-tree whole frontend type-aware lint/typecheck passes. Unit frontend: + 132 files / 1,576 tests pass. Actual Cloudflare build + all 30 engine/browser + cases pass (49.5 s), including the new catalog normalization round trip. The + native/browser 61 generators, result schemas/hashes, loader/memory/init/parity + cases remain unchanged and passing. +- Broader current built-site settings/command/modal/quote/restart/external-content + check: 54 pass (1.8 m). Early loader-count regression exposed unintended proof + preloading; isolated the probe, preserved its original six-download assertion, + and reran all 30 successfully. Hook inventory now explicitly accounts for Rust + word hooks and checks every remaining browser implementation. +- Remaining before Phase 2.3 sign-off: new actual-website delayed section batch, + navigation cancellation and failed-source recovery checks. Added lease-aware + cancellation handling and malformed-source rejection; these latest small edits + are not included in the 30/54/1,576-pass evidence above and require verification. +- Then commit remaining coherent settings/host changes, update inventory/features, + and continue Phase 2.4 result analysis/playback. Phase 2.5/2.6 and Stage 3 still + pending. Never claim the migration or terminal release finished at this checkpoint. + +## Phase 2.3 gate passed + +- Native policies, catalogs, generation, repeat/restart, pace, learning and practice + now serve browser consumers. Host hooks retain display/audio/platform I/O. +- Three built-site source edge checks pass (9.1 s): delayed 408-unit batch retains + original zero testMs; navigation aborts without stale input/unhandled rejection; + failed active source recovers without scoring a failed typing test. +- Extracted SSR-safe settings/themes/metadata into `@oxytype/web-shared`; native + defaults authoritative. Preserved original default enumeration order, which + controls legacy full-import preference sequencing. Existing 25 setter regressions + caught that dependency and pass after correction; all 372 core tests pass. +- Fixed saved-funbox startup race. Full constrained imports await native policy + readiness before mutation. Failed local startup retains the original config for + Restart; it never saves an incomplete replacement. Actual delayed/503 browser + cases now use saved ALL_CAPS settings and verify they survive both paths. +- Current built Cloudflare input/source/settings-loading gate: 8 pass (31.2 s). + Workerd SSR document gate: 6 pass (8.63 s). Server JS scan contains no engine + initializer/Wasm import. Whole frontend/shared type-aware lint passes. Dependency + graph: 789 files, no cycle. +- Frontend unit gate: 132 files / 1,577 tests pass (23.23 s, maxWorkers=4). + Earlier simultaneous build/full-test runs exceeded the palette cold-import + test's existing 5 s deadline; isolated test and bounded-worker whole suite pass + without changing deadlines/assertions. Actual browser settings/catalog evidence + from the previous checkpoint remains valid for unchanged native outputs. +- Continue Phase 2.4 now. Helpers still used by results are deliberately retained + until that gate; legacy oracle re-exports retire in Phase 2.6. Stage 3 pending. + +## Phase 2.4 gate passed + +- Rust now owns completion validity/AFK decisions, long custom progress, result + histories/raw/burst/accuracy/chart buckets, event-log inspector reconstruction, + and replay prompt mutation/seeking. The website retains transport/hash/account + metadata, tags/challenges, saved-text storage, screenshots, display and timers. +- New immutable inspector reference: 64 prefixes across eight original web logs. + Native tests and optimized Wasm check exact DOM text, last input position, + accuracy, unreleased timing placeholders and unlagged chart labels. One-time + source archived as inert provenance after original inspector removal. +- App-owned WeakMaps cache immutable result/prefix views without retaining logs. + Replay handles/timers are route-owned and disposed on exit; original seven + replay snapshots and component playback/seek expectations remain unchanged. +- Explicit binding contract: infinite per-word burst is JSON null; the host + restores Infinity for display. Native f64 stats are unchanged; completed-result + finite validation stays strict. A temporary redundant Option allocation was + removed; final payload 774,741 raw / 306,988 gzip6 bytes, within frozen limits. +- Native full suite, Clippy, format and whole frontend type-aware checks pass. + Unit + JSX: 224 files / 2,191 tests pass (62.83 s). Two added native-completion + long-book tests also pass in the 18-case lifecycle file (partial bailout saves + progress; completing the remaining tail resets progress/restores full text). +- Built Cloudflare proof: original 19 cases pass, including all original generator, + result/hash/schema/backend/replay references. New 64-prefix inspector browser + case passes separately (11.7 s after fixing its fs read). Signed-out native result + history/clipboard/replay seek passes (12.5 s); authenticated same-payload save + retry and invalid short-test non-submission pass (2 cases, 15.6 s). +- Retry fixture needed the seeded Start session and settled account config; its + earlier guest/mid-settings attempts were fixture failures. Existing real browser + completion through Start/D1 passes (14.5 s): one persisted result/account update. + Independent backend result (21) and anticheat detector (19) checks pass unchanged. +- All result browser cases added to engine browser CI task. No production/staging + publication. Continue Phase 2.5; TypeScript retirement and terminal remain open. + +## Phase 2.5 active checkpoint + +- Fixed public asset retention to include hashed Wasm alongside matched glue; + service-worker precache explicitly includes browser SDK and Wasm. Nine retention/ + precache unit checks and eight built workerd delivery checks pass (MIME, immutable + headers, complete cache and exact published bytes). Wrangler dry-run passes. +- Long real-keyboard profiling exposed an attached-generator zen bug: refill + generated English targets behind the cursor. Rust now keeps zen targetless; + component-owned rendering projects native word indices into empty display nodes. + Native parity, full native tests/Clippy/format and actual browser backward/restart + regression pass. No reference changed. +- Full website baseline passes unchanged budgets: 20 input/restart samples plus + 2,000 keyboard characters, no page errors. Active JS 13,185,628 bytes; reset JS + 13,006,284; reset Wasm 15,335,424 (<16 MiB). Three additional real-keyboard 2,000-unit + sessions remain flat at 15,335,424 Wasm bytes; reset JS maximum 12,227,996. + Raw measurements: website-candidate-{readiness,input,soak}.json. Earlier added-soak + attempt hit the original baseline deadline and exposed the zen defect; repeated + workloads now have a separate test, leaving original timing budgets unchanged. +- Cold/warm startup gate passes (10 samples each), as do input-paint and restart + median/p95 gates. Current payload 774,770 raw bytes, gzip6 still within unchanged + 307,200-byte build guard. Whole changed-source type-aware check passes. +- Actual matched-build update/rollback suite: three pass (38.2 s). Old tab resolves + its engine/glue after deployment, both caches contain engine assets, rollback + types offline; pending real save and removed-function retry remain compatible. +- Remaining gate: clean checkout, current whole proof/SSR/browser regression and + controlled initialization/boundary distribution. Then Phase 2.6 retirement. + No external deployment, release or Stage 3 completion claimed. + +## Phase 2.5 gate passed + +- Fresh detached checkout `24b3a3819`: frozen install, no generated/target/dist; + actual Cloudflare build automatically produces Rust/Wasm/bindings. All workspace + typechecks pass. Fresh workerd preview: 14 document/delivery cases pass (5.36 s). +- Main current-build Start suite: 14 files / 86 checks pass (48.69 s). Combined engine + suite: 37 pass; one replay UI fixture clicked an invisible separator on a random + one-letter word. Corrected locator to its visible first letter; rerun passes. +- Controlled final browser checks: three pass (35.5 s), including replay history/ + clipboard/seek, original native fixture and 30 fresh initialization contexts. + Initialization p95 66.7 ms (<100); boundary and deployed payload/glue checks pass. + Raw website-engine-{initialization,boundary}.json retained separately. +- No server-side engine initialization; no backend validation changes. Ready for + release review through the established matched-artifact workflow; hosted staging + and production deployment remain unperformed and are not implicitly authorized. +- Continue immediately with TypeScript retirement. Additional synthetic legacy + analysis calls captured once before deleting that test-only recorder; native + parity verification is in progress. Original reference snapshots remain unchanged. + +## Phase 2.6 active checkpoint — legacy removal + +- Extracted host helpers and legacy wire DTO types into `web-shared`; 114 moved + utility tests pass. Removed all TypeScript engine sources/package/dependency, + unused rule facades and transitional test-only recorder. Retained every original + reference byte; original manifest unchanged. Capture scripts archived inertly. +- Captured 67 synthetic public analysis calls and 68 string-rule calls once, before + deletion. Original legacy suites pass (100 and 157 cases respectively); all 135 + native comparisons pass. Full native suite, Clippy and format pass. +- Browser anticheat regression now passes actual optimized-Wasm summaries into the + unchanged independent backend validators, removing its previous JS score formulas. + Host input capture and timer scheduler tests no longer import the legacy engine. +- Changed host/input/string/anticheat group: 126 pass. Whole workspace unit/JSX run: + 338 files / 3,387 pass, one CI fixture-routing expectation failed because immutable + fixtures now correctly select the full engine gate. Corrected routing assertion; + all 68 routing/metadata/reference-integrity checks pass. No reference regenerated. +- Whole frontend/shared type-aware check passes. Two earlier transient tsgolint + panics came from deleting a file during a running scan; subsequent complete check + after edits settled passes. Do not mutate scanned trees while lint/typecheck runs. +- Remaining: current retired-engine production build/browser/full-workspace types, + final fresh checkout, performance confirmation, rollback documentation and PR2 + sign-off. Stage 3 still pending; no live deployment performed. + +## Cross-platform delivery follow-up + +- Current retirement checkout `19871ed8e`: frozen install, automatic fresh Rust/Wasm + Cloudflare build and all nine remaining workspace typechecks pass without the + legacy package. Broad website suite: 146 pass (5.4 m); the single authenticated + save test raced account settings (14/15 typed after config replacement). Waiting + for settled fixture settings fixes it; isolated existing test passes (14.6 s). +- x86_64 CI run `40041357470` failed during common bootstrap: Wasm 775,342 raw / + 307,376 gzip6, exceeding the unchanged 307,200-byte limit. ARM passing locally + was insufficient evidence; cross-platform payload gate reopened before sign-off. +- Borrow timing codes instead of allocating strings per event; exact native parity + passes. Float-label and cold-error/extra Binaryen experiments did not produce + sufficient gzip savings and were discarded, not shipped. +- Share the already-required JSON Value parser for the immutable British dictionary, + eliminating its redundant untagged-enum deserializer. Dictionary bytes unchanged; + explicit schema/replacement test and full native parity/Clippy pass. Current ARM + payload 765,852 raw / 303,619 gzip6, leaving 3,581 bytes of unchanged-budget margin. +- Wasm cache inputs now cover actual engine/adapter sources, data, manifests, build + scripts, environment/toolchain and output hashes. Native tests and future terminal + sources cannot affect generated browser output and no longer force Wasm rebuilds. +- Pushed through `3d7a509a2`; current optimized browser/performance and new x86_64 CI + remain pending. Stage 2 is not signed off yet; Stage 3 remains pending. + +## Final startup and quality follow-up + +- Optimized actual Cloudflare browser suite: all 30 pass (1.1 m), including frozen + generators/results/inspection/replay, retries, input/IME, lifecycle/memory and + controlled initialization (p95 60.9 ms). Raw retired-engine artifacts retained. +- Whole-website input/restart/heap and long/repeated-session gates pass; reset Wasm + remains 15,335,424 bytes. Cold-readiness median initially failed at 1,078.3 ms. + Trace shows startup-cover polling falls into a later interval. Added SSR-safe + hashed Wasm URL metadata and document preload so transfer overlaps hydration; + there is still no server initializer. After change cold median 684.3 ms / p95 + 1,159.5; warm median 596.3 / p95 734.8, within unchanged budgets. Failed raw run + retained separately in diagnostics, never substituted for the frozen baseline. +- Actual workerd head/precache/MIME/CSP/cache gate: 14 pass (5.53 s). Browser loading/ + saved-settings/retry plus frozen native trace proof: three pass (11.7 s). Updated + matched-artifact deploy/rollback suite: three pass (37.2 s), including offline typing. +- CI `40042712384` passes native/Wasm payload and functional jobs; quality found four + dead exports plus a false unused-base-tsconfig report. Removed dead APIs and used + the existing SDK Knip convention for the actual tsconfig dependency. Knip and + type-aware checks pass locally; changed host/render tests: 35 pass. +- Pushed `0b17aabeb`; final fresh checkout/build/types and resulting CI gate still + running. No Stage 2 sign-off or terminal completion claimed yet. + +## Stage 2 gate passed + +- Final runtime source `0b17aabeb`; full CI `40043812060` succeeds: native/Wasm, + package/backend/frontend, quality/format/Knip, SSR, delivery/assets, four broad + browser shards and update/rollback jobs. No weakened checks or regenerated + reference snapshots. PR #185 is ready for review, stacked on #184. +- Fresh isolated checkout at the final runtime source, with target/generated/dist + removed: frozen install, automatic Cloudflare Rust/Wasm build and all nine + workspace typechecks pass. Current Wrangler dry-run and whole-repo format pass. +- Final built browser gate: all 38 pass (1.3 m) with controlled timing enforcement, + including new Wasm preload and exact original behavior/result/hash contracts. + Full website cold/warm, input/restart, heap and long/repeated-session budgets + pass; raw evidence and failed diagnostic run retained. Wasm 765,852 raw / + 303,619 gzip6 bytes. Stage 2 contains 70+ coherent commits, without empty commits. +- Website is ready for the established release review/deployment process. Hosted + staging/production deployment, current Cloudflare rollback-version capture and + actual hardware IME/OS checks remain unperformed; no publication authorized or + claimed. Local matched-artifact rollback/offline/save checks pass. +- Continue Stage 3 immediately. Native Ratatui implementation, capabilities, local + history and platform release gates remain required; do not stop at this stage. diff --git a/docs/rust-engine/website-candidate-input.json b/docs/rust-engine/website-candidate-input.json new file mode 100644 index 0000000000..f7560111b0 --- /dev/null +++ b/docs/rust-engine/website-candidate-input.json @@ -0,0 +1,523 @@ +{ + "browser": "153.0.8010.12", + "fixture": "built Start Worker; desktop Chromium; zen; 20 input/restart samples; double-rAF paint; 2000 keyboard characters; forced GC", + "inputToPaint": [ + 86.19999998807907, 67.09999999403954, 46.69999998807907, 46.5, + 54.39999997615814, 65.5, 29.80000001192093, 62.5, 65, 75.09999999403954, + 52.5, 30, 66.90000000596046, 49.69999998807907, 65.80000001192093, + 35.10000002384186, 69.7000000178814, 53.400000005960464, 64.80000001192093, + 73.2000000178814 + ], + "restart": [ + 373.62600799999996, 357.44521999999984, 384.92107699999997, + 358.86493599999994, 384.9804509999999, 237.9461409999999, 359.3881750000005, + 374.377657, 399.0433720000001, 351.6257310000001, 361.10071700000026, + 246.8858479999999, 348.3445160000001, 246.8721400000004, 346.27131400000053, + 385.78743199999917, 383.21878300000026, 345.81545000000006, + 379.14958599999954, 384.08367999999973 + ], + "initialHeap": 9173508, + "activeHeap": 13185628, + "resetHeap": 13006284, + "initialWasm": 1310720, + "activeWasm": 4980736, + "resetWasm": 15335424, + "assets": [ + { + "name": "/assets/index-2IZha-U2.js", + "bytes": 120318, + "transfer": 48319 + }, + { + "name": "/assets/web-DoCSsFQb.js", + "bytes": 32378, + "transfer": 12617 + }, + { + "name": "/assets/redirect-D6mi4iKQ.js", + "bytes": 848, + "transfer": 784 + }, + { + "name": "/assets/matchContext-CJdPZbyb.js", + "bytes": 106, + "transfer": 410 + }, + { + "name": "/assets/not-found-i5RsCZif.js", + "bytes": 118, + "transfer": 411 + }, + { + "name": "/assets/link-BBh-inDq.js", + "bytes": 62968, + "transfer": 22646 + }, + { + "name": "/assets/ClientOnly-BFOvw4I-.js", + "bytes": 315, + "transfer": 528 + }, + { + "name": "/assets/useRouter-Ca7pFXE8.js", + "bytes": 103, + "transfer": 414 + }, + { + "name": "/assets/application-BO7jqupq.js", + "bytes": 1199, + "transfer": 898 + }, + { + "name": "/assets/store-D8POwzGv.js", + "bytes": 6673, + "transfer": 2688 + }, + { + "name": "/assets/createServerFn-DlBie0Bv.js", + "bytes": 36231, + "transfer": 11909 + }, + { + "name": "/assets/build-UMZXoVWB.js", + "bytes": 15058, + "transfer": 5010 + }, + { + "name": "/assets/queryObserver-D957XF2z.js", + "bytes": 20747, + "transfer": 7040 + }, + { + "name": "/assets/preload-helper-BZ1Pz5am.js", + "bytes": 1218, + "transfer": 988 + }, + { + "name": "/assets/zod-messages-CVOW0Ljn.js", + "bytes": 98218, + "transfer": 27639 + }, + { + "name": "/assets/dist-eMfna9Da.js", + "bytes": 2706, + "transfer": 1357 + }, + { + "name": "/assets/skeleton-CWhZcH5J.js", + "bytes": 2536, + "transfer": 1185 + }, + { + "name": "/assets/cn-DLyFV4qv.js", + "bytes": 27805, + "transfer": 9057 + }, + { + "name": "/assets/skeleton-CtcKlTjt.js", + "bytes": 417, + "transfer": 608 + }, + { + "name": "/assets/skeleton-text-CMtoHw-F.js", + "bytes": 390, + "transfer": 591 + }, + { + "name": "/assets/shared-BCIotZRx.js", + "bytes": 8560, + "transfer": 3667 + }, + { + "name": "/assets/_app-_gS1HEeE.js", + "bytes": 158, + "transfer": 452 + }, + { + "name": "/assets/skeleton-DZbZqgc0.js", + "bytes": 1235, + "transfer": 910 + }, + { + "name": "/assets/_app.index-C7j7L309.js", + "bytes": 2747, + "transfer": 1650 + }, + { + "name": "/assets/browser-runtime.client-GUHOpX01.js", + "bytes": 13615, + "transfer": 6147 + }, + { + "name": "/assets/personal-bests-BTTiFqKP.js", + "bytes": 83241, + "transfer": 26723 + }, + { + "name": "/assets/default-config-BOUeDW9b.js", + "bytes": 3599, + "transfer": 1834 + }, + { + "name": "/assets/test-activity-calendar-B_1v4b-J.js", + "bytes": 5567, + "transfer": 2507 + }, + { + "name": "/assets/configs-u1m563Sg.js", + "bytes": 9865, + "transfer": 5176 + }, + { + "name": "/assets/users-Cs9QvXZ-.js", + "bytes": 9274, + "transfer": 4599 + }, + { + "name": "/assets/_virtual_env-config-DBO1X1jG.js", + "bytes": 196, + "transfer": 481 + }, + { + "name": "/assets/useRef-9UY2IK1A.js", + "bytes": 98, + "transfer": 415 + }, + { + "name": "/assets/app-BStOgWeU.js", + "bytes": 54030, + "transfer": 17181 + }, + { + "name": "/assets/test-B09U2qyg.js", + "bytes": 15382, + "transfer": 6581 + }, + { + "name": "/assets/createEvent-DUwnlqx6.js", + "bytes": 313, + "transfer": 528 + }, + { + "name": "/assets/failure-DwH656nh.js", + "bytes": 644, + "transfer": 647 + }, + { + "name": "/assets/useLocalStorage-CYG1IBc2.js", + "bytes": 2850, + "transfer": 1722 + }, + { + "name": "/assets/effects-CL70M349.js", + "bytes": 38437, + "transfer": 15619 + }, + { + "name": "/assets/text-EJZLk8Td.js", + "bytes": 840, + "transfer": 755 + }, + { + "name": "/assets/parse-C_h2zvC4.js", + "bytes": 5672, + "transfer": 2774 + }, + { + "name": "/assets/connection-BW6i5z7G.js", + "bytes": 6928, + "transfer": 3162 + }, + { + "name": "/assets/connection-CBiej-px.js", + "bytes": 2162, + "transfer": 1473 + }, + { + "name": "/assets/build-hTByFGMt.js", + "bytes": 3318, + "transfer": 1634 + }, + { + "name": "/assets/auth-client-DmthCZJV.js", + "bytes": 1099, + "transfer": 908 + }, + { + "name": "/assets/popup.client-BAQvC3xB.js", + "bytes": 31904, + "transfer": 12212 + }, + { + "name": "/assets/auth-DGEadsVf.js", + "bytes": 6921, + "transfer": 3214 + }, + { + "name": "/assets/date-and-time-DSi1veiI.js", + "bytes": 964, + "transfer": 796 + }, + { + "name": "/assets/settled.client-Ddp-TQeE.js", + "bytes": 530, + "transfer": 643 + }, + { + "name": "/assets/settled.client-By9Nwtmv.js", + "bytes": 349, + "transfer": 557 + }, + { + "name": "/assets/query-cache.client-CHm7QVEu.js", + "bytes": 3064, + "transfer": 1726 + }, + { + "name": "/assets/update-safety-CXRnqrdG.js", + "bytes": 140, + "transfer": 442 + }, + { + "name": "/assets/cookies-DugnrCnN.js", + "bytes": 698, + "transfer": 731 + }, + { + "name": "/assets/glarses-mode-BchkrWjY.js", + "bytes": 285, + "transfer": 522 + }, + { + "name": "/assets/fonts.client-FJtIUqN8.js", + "bytes": 840, + "transfer": 817 + }, + { + "name": "/assets/rolldown-runtime-Dd_uD5pT.js", + "bytes": 1106, + "transfer": 927 + }, + { + "name": "/assets/chrome.client-Df-T9Gib.js", + "bytes": 41670, + "transfer": 14476 + }, + { + "name": "/assets/hotkeys-8mgLAoHN.js", + "bytes": 17130, + "transfer": 6676 + }, + { + "name": "/assets/Kbd-DPDwH4vf.js", + "bytes": 1953, + "transfer": 1436 + }, + { + "name": "/assets/overlay-visibility-CHOMN59j.js", + "bytes": 181, + "transfer": 461 + }, + { + "name": "/assets/UpdateNameModal-BUDj-bKH.js", + "bytes": 6846, + "transfer": 3191 + }, + { + "name": "/assets/simple-modal-DVjRjV0X.js", + "bytes": 255, + "transfer": 502 + }, + { + "name": "/assets/utils-oI_LPHw2.js", + "bytes": 638, + "transfer": 666 + }, + { + "name": "/assets/breakpoints-COUzW0ds.js", + "bytes": 111, + "transfer": 409 + }, + { + "name": "/assets/modifier-keys-CBdW-khj.js", + "bytes": 165, + "transfer": 437 + }, + { + "name": "/assets/navigate-H3LNuvCa.js", + "bytes": 268, + "transfer": 469 + }, + { + "name": "/assets/anime-oiKFm9uY.js", + "bytes": 5568, + "transfer": 2630 + }, + { + "name": "/assets/levels-CSY-AKc3.js", + "bytes": 414, + "transfer": 590 + }, + { + "name": "/assets/page.client-C0PM2KOi.js", + "bytes": 77241, + "transfer": 24027 + }, + { + "name": "/assets/mode-BrULBDpK.js", + "bytes": 258, + "transfer": 475 + }, + { + "name": "/assets/modifiers-DQEiUe6S.js", + "bytes": 1872, + "transfer": 1179 + }, + { + "name": "/assets/quote-rate-Dkcm1kz3.js", + "bytes": 49881, + "transfer": 17156 + }, + { + "name": "/assets/test-words-M3PA5Cpo.js", + "bytes": 1064, + "transfer": 853 + }, + { + "name": "/assets/practise-words-DfHMF5E3.js", + "bytes": 1206, + "transfer": 970 + }, + { + "name": "/assets/test-logic-9HrsdQSi.js", + "bytes": 15609, + "transfer": 6668 + }, + { + "name": "/assets/content.client-DJJ8s10K.js", + "bytes": 6793, + "transfer": 3274 + }, + { + "name": "/assets/quotes-controller-DskMjPqg.js", + "bytes": 1124, + "transfer": 901 + }, + { + "name": "/assets/format-C7MeQeCA.js", + "bytes": 187, + "transfer": 461 + }, + { + "name": "/assets/open-COO8Zczx.js", + "bytes": 253, + "transfer": 493 + }, + { + "name": "/assets/challenge-controller-BevqJmFE.js", + "bytes": 3977, + "transfer": 1884 + }, + { + "name": "/assets/lz-ts-BjRkF-Z2.js", + "bytes": 3789, + "transfer": 1602 + }, + { + "name": "/assets/browser.client-0IK8a3GF.js", + "bytes": 13225, + "transfer": 3750 + }, + { + "name": "/assets/engine_bg-uJYjmNJr.wasm", + "bytes": 774770, + "transfer": 307413 + }, + { + "name": "/assets/Result-C2r5qOcr.js", + "bytes": 36169, + "transfer": 12570 + }, + { + "name": "/assets/tags-Z3abXkz4.js", + "bytes": 6061, + "transfer": 2484 + }, + { + "name": "/assets/typing-speed-units-3C-mE-p9.js", + "bytes": 37, + "transfer": 357 + }, + { + "name": "/assets/reader-Bt26aDGi.js", + "bytes": 336480, + "transfer": 92657 + }, + { + "name": "/assets/UserFlags-_vCx-8K2.js", + "bytes": 994, + "transfer": 789 + }, + { + "name": "/assets/edit-result-tags-DF4-G5Wc.js", + "bytes": 294, + "transfer": 533 + }, + { + "name": "/assets/result-D3ijt6cH.js", + "bytes": 25338, + "transfer": 9727 + }, + { + "name": "/assets/today-tracker-Bj6aZ_ex.js", + "bytes": 2353, + "transfer": 1483 + }, + { + "name": "/assets/test-screenshot-CtXKeaZz.js", + "bytes": 4903, + "transfer": 2609 + }, + { + "name": "/assets/quote-report-DGCJPOmP.js", + "bytes": 163, + "transfer": 454 + }, + { + "name": "/assets/completion.client-YGkg9bct.js", + "bytes": 5418, + "transfer": 2957 + }, + { + "name": "/assets/result-saving-DhjRy1Wu.js", + "bytes": 5023, + "transfer": 2571 + }, + { + "name": "/assets/ResultChart-CuHw_BmG.js", + "bytes": 6022, + "transfer": 2754 + }, + { + "name": "/assets/ChartJs-DKs2gS8r.js", + "bytes": 74721, + "transfer": 22585 + }, + { + "name": "/assets/compareAsc-D4Ojto6V.js", + "bytes": 105, + "transfer": 422 + }, + { + "name": "/assets/endOfWeek-CFzE2uT2.js", + "bytes": 1860, + "transfer": 1085 + }, + { + "name": "/assets/chart-Bx03QiIs.js", + "bytes": 177979, + "transfer": 61565 + } + ], + "errors": [] +} diff --git a/docs/rust-engine/website-candidate-readiness.json b/docs/rust-engine/website-candidate-readiness.json new file mode 100644 index 0000000000..2515dcb75a --- /dev/null +++ b/docs/rust-engine/website-candidate-readiness.json @@ -0,0 +1,18 @@ +{ + "fixture": "production assets, guest API fixtures, desktop Chromium, HTTP cache cold/warm, SW bypass", + "cold": [ + 1190.5, 591, 1062.199999988079, 1065.699999988079, 578.0999999940395, + 566.6999999880791, 1147.7000000178814, 1182.199999988079, 624.2999999821186, + 671.2000000178814 + ], + "warm": [ + 502.60000002384186, 630.3000000119209, 710.6999999880791, 612.9000000059605, + 591.6999999880791, 593.9000000059605, 678.4000000059605, 581.9000000059605, + 680, 596.5999999940395 + ], + "settings": [ + 562.7000000178814, 504.90000000596046, 514.5, 497.5, 501.69999998807907, + 551.8999999761581, 568.5999999940395, 508.39999997615814, 520.0999999940395, + 515.3999999761581 + ] +} diff --git a/docs/rust-engine/website-candidate-soak.json b/docs/rust-engine/website-candidate-soak.json new file mode 100644 index 0000000000..9b9bfeee23 --- /dev/null +++ b/docs/rust-engine/website-candidate-soak.json @@ -0,0 +1,18 @@ +{ + "fixture": "3 real-keyboard 2000-unit zen sessions; restart and forced GC", + "samples": [ + { + "heap": 11912284, + "wasm": 15335424 + }, + { + "heap": 12128376, + "wasm": 15335424 + }, + { + "heap": 12227996, + "wasm": 15335424 + } + ], + "errors": [] +} diff --git a/docs/rust-engine/website-engine-boundary.json b/docs/rust-engine/website-engine-boundary.json new file mode 100644 index 0000000000..41775cdd45 --- /dev/null +++ b/docs/rust-engine/website-engine-boundary.json @@ -0,0 +1,41 @@ +{ + "fixture": "built Cloudflare Start; Desktop Chrome; 50 warmed JSON samples; 200 UTF-16 units per sample; 7 real keyboard commits with double-rAF paint", + "browser": "153.0.8010.12", + "wasmBytes": 774770, + "wasmHeaders": { + "transfer-encoding": "chunked", + "cache-control": "public, max-age=31536000, immutable", + "content-encoding": "gzip", + "cf-cache-status": "HIT", + "etag": "\"4194b6383698434a37a61803a4b059ca\"", + "content-type": "application/wasm", + "x-content-type-options": "nosniff" + }, + "inputToPaint": [ + 38.099999994039536, 24.799999982118607, 21.5, 26.900000005960464, + 21.599999994039536, 23.5, 22.30000001192093 + ], + "benchmark": { + "initializationMs": 33.099999994039536, + "jsonMs": [ + 0.011500000059604644, 0.010999999940395355, 0.011000000089406967, + 0.010999999940395355, 0.010500000119209289, 0.010999999940395355, + 0.010499999970197677, 0.00949999988079071, 0.00949999988079071, + 0.00949999988079071, 0.00949999988079071, 0.008999999910593033, + 0.009500000029802322, 0.009500000029802322, 0.009500000029802322, + 0.009000000059604645, 0.009500000029802322, 0.008999999910593033, + 0.008499999940395355, 0.009500000029802322, 0.008500000089406967, + 0.008499999940395355, 0.008499999940395355, 0.009000000059604645, + 0.009500000029802322, 0.008999999910593033, 0.008499999940395355, + 0.008500000089406967, 0.008499999940395355, 0.0125, 0.008499999940395355, + 0.009000000059604645, 0.007999999970197678, 0.007999999970197678, + 0.008499999940395355, 0.008999999910593033, 0.008500000089406967, + 0.007999999970197678, 0.008500000089406967, 0.008499999940395355, + 0.007999999970197678, 0.007999999970197678, 0.008499999940395355, + 0.007999999970197678, 0.007999999970197678, 0.007999999970197678, + 0.008499999940395355, 0.007999999970197678, 0.007999999970197678, + 0.009000000059604645 + ], + "memoryBytes": 1441792 + } +} diff --git a/docs/rust-engine/website-engine-initialization.json b/docs/rust-engine/website-engine-initialization.json new file mode 100644 index 0000000000..995282a502 --- /dev/null +++ b/docs/rust-engine/website-engine-initialization.json @@ -0,0 +1,127 @@ +{ + "browser": "153.0.8010.12", + "fixture": "30 fresh browser contexts; HTTP cache disabled; built Cloudflare Worker; from browser-only import through usable finite session; no CPU/network throttle", + "samples": [ + { + "initializationMs": 41.5, + "memory": 1245184 + }, + { + "initializationMs": 33, + "memory": 1245184 + }, + { + "initializationMs": 40.5, + "memory": 1245184 + }, + { + "initializationMs": 35.400000005960464, + "memory": 1245184 + }, + { + "initializationMs": 39.5, + "memory": 1245184 + }, + { + "initializationMs": 38.20000001788139, + "memory": 1245184 + }, + { + "initializationMs": 40.20000001788139, + "memory": 1245184 + }, + { + "initializationMs": 33.900000005960464, + "memory": 1245184 + }, + { + "initializationMs": 56.39999997615814, + "memory": 1245184 + }, + { + "initializationMs": 38.599999994039536, + "memory": 1245184 + }, + { + "initializationMs": 81.40000000596046, + "memory": 1245184 + }, + { + "initializationMs": 53.900000005960464, + "memory": 1245184 + }, + { + "initializationMs": 63.599999994039536, + "memory": 1245184 + }, + { + "initializationMs": 58.69999998807907, + "memory": 1245184 + }, + { + "initializationMs": 38.10000002384186, + "memory": 1245184 + }, + { + "initializationMs": 66.7000000178814, + "memory": 1245184 + }, + { + "initializationMs": 65.7000000178814, + "memory": 1245184 + }, + { + "initializationMs": 35.5, + "memory": 1245184 + }, + { + "initializationMs": 40.19999998807907, + "memory": 1245184 + }, + { + "initializationMs": 32.599999994039536, + "memory": 1245184 + }, + { + "initializationMs": 45.10000002384186, + "memory": 1245184 + }, + { + "initializationMs": 60.099999994039536, + "memory": 1245184 + }, + { + "initializationMs": 40.29999998211861, + "memory": 1245184 + }, + { + "initializationMs": 41, + "memory": 1245184 + }, + { + "initializationMs": 38.70000001788139, + "memory": 1245184 + }, + { + "initializationMs": 39.89999997615814, + "memory": 1245184 + }, + { + "initializationMs": 35.099999994039536, + "memory": 1245184 + }, + { + "initializationMs": 41.30000001192093, + "memory": 1245184 + }, + { + "initializationMs": 38.900000005960464, + "memory": 1245184 + }, + { + "initializationMs": 39.30000001192093, + "memory": 1245184 + } + ], + "p95": 66.7000000178814 +} diff --git a/docs/rust-engine/website-preloaded-readiness.json b/docs/rust-engine/website-preloaded-readiness.json new file mode 100644 index 0000000000..f63e99c35c --- /dev/null +++ b/docs/rust-engine/website-preloaded-readiness.json @@ -0,0 +1,17 @@ +{ + "fixture": "production assets, guest API fixtures, desktop Chromium, HTTP cache cold/warm, SW bypass", + "cold": [ + 676.9000000059605, 1057.0999999940395, 684.2999999821186, 611.8000000119209, + 1159.5, 1065.5999999940395, 557.4000000059605, 1152.0999999940395, + 1023.7000000178814, 663.6999999880791 + ], + "warm": [ + 560.5, 611.6999999880791, 579.2999999821186, 655.6000000238419, + 616.8000000119209, 535.4000000059605, 734.7999999821186, 630, 487, + 596.2999999821186 + ], + "settings": [ + 551.6999999880791, 501.09999999403954, 562.8999999761581, 547.2999999821186, + 560, 504.19999998807907, 590.5, 526.8000000119209, 525.5, 515.1999999880791 + ] +} diff --git a/docs/rust-engine/website-retired-boundary.json b/docs/rust-engine/website-retired-boundary.json new file mode 100644 index 0000000000..9de088679b --- /dev/null +++ b/docs/rust-engine/website-retired-boundary.json @@ -0,0 +1,39 @@ +{ + "fixture": "built Cloudflare Start; Desktop Chrome; 50 warmed JSON samples; 200 UTF-16 units per sample; 7 real keyboard commits with double-rAF paint", + "browser": "153.0.8010.12", + "wasmBytes": 765852, + "wasmHeaders": { + "transfer-encoding": "chunked", + "cache-control": "public, max-age=31536000, immutable", + "content-encoding": "gzip", + "cf-cache-status": "HIT", + "etag": "\"135107efb2e339c9877d2c00d84d5945\"", + "content-type": "application/wasm", + "x-content-type-options": "nosniff" + }, + "inputToPaint": [ + 37.099999994039536, 24.5, 23.900000005960464, 20.399999976158142, + 37.60000002384186, 31, 29.600000023841858 + ], + "benchmark": { + "initializationMs": 32.400000005960464, + "jsonMs": [ + 0.010999999940395355, 0.01, 0.010999999940395355, 0.010499999970197677, + 0.01, 0.01, 0.01, 0.010500000119209289, 0.01, 0.010499999970197677, + 0.009500000029802322, 0.01, 0.01, 0.009500000029802322, 0.01, + 0.009500000029802322, 0.01, 0.01, 0.009500000029802322, + 0.009000000059604645, 0.009500000029802322, 0.009500000029802322, + 0.009000000059604645, 0.009000000059604645, 0.008499999940395355, + 0.008499999940395355, 0.009000000059604645, 0.008499999940395355, + 0.008499999940395355, 0.008500000089406967, 0.012000000029802322, + 0.008999999910593033, 0.008999999910593033, 0.008500000089406967, + 0.008999999910593033, 0.008499999940395355, 0.008500000089406967, + 0.008499999940395355, 0.008500000089406967, 0.008499999940395355, + 0.007999999970197678, 0.008500000089406967, 0.008499999940395355, + 0.008500000089406967, 0.008499999940395355, 0.008500000089406967, + 0.007999999970197678, 0.007999999970197678, 0.007999999970197678, + 0.00800000011920929 + ], + "memoryBytes": 1441792 + } +} diff --git a/docs/rust-engine/website-retired-initialization.json b/docs/rust-engine/website-retired-initialization.json new file mode 100644 index 0000000000..9d8ee15396 --- /dev/null +++ b/docs/rust-engine/website-retired-initialization.json @@ -0,0 +1,127 @@ +{ + "browser": "153.0.8010.12", + "fixture": "30 fresh browser contexts; HTTP cache disabled; built Cloudflare Worker; from browser-only import through usable finite session; no CPU/network throttle", + "samples": [ + { + "initializationMs": 45.099999994039536, + "memory": 1245184 + }, + { + "initializationMs": 44, + "memory": 1245184 + }, + { + "initializationMs": 40.30000001192093, + "memory": 1245184 + }, + { + "initializationMs": 36.400000005960464, + "memory": 1245184 + }, + { + "initializationMs": 37.80000001192093, + "memory": 1245184 + }, + { + "initializationMs": 37.099999994039536, + "memory": 1245184 + }, + { + "initializationMs": 42.900000005960464, + "memory": 1245184 + }, + { + "initializationMs": 32.19999998807907, + "memory": 1245184 + }, + { + "initializationMs": 39.19999998807907, + "memory": 1245184 + }, + { + "initializationMs": 37.900000005960464, + "memory": 1245184 + }, + { + "initializationMs": 35.80000001192093, + "memory": 1245184 + }, + { + "initializationMs": 41.69999998807907, + "memory": 1245184 + }, + { + "initializationMs": 52.400000005960464, + "memory": 1245184 + }, + { + "initializationMs": 41.099999994039536, + "memory": 1245184 + }, + { + "initializationMs": 38.599999994039536, + "memory": 1245184 + }, + { + "initializationMs": 37.599999994039536, + "memory": 1245184 + }, + { + "initializationMs": 36.10000002384186, + "memory": 1245184 + }, + { + "initializationMs": 41.599999994039536, + "memory": 1245184 + }, + { + "initializationMs": 40.599999994039536, + "memory": 1245184 + }, + { + "initializationMs": 38.400000005960464, + "memory": 1245184 + }, + { + "initializationMs": 69.2999999821186, + "memory": 1245184 + }, + { + "initializationMs": 40.10000002384186, + "memory": 1245184 + }, + { + "initializationMs": 60.89999997615814, + "memory": 1245184 + }, + { + "initializationMs": 40.5, + "memory": 1245184 + }, + { + "initializationMs": 39.099999994039536, + "memory": 1245184 + }, + { + "initializationMs": 54, + "memory": 1245184 + }, + { + "initializationMs": 40.70000001788139, + "memory": 1245184 + }, + { + "initializationMs": 43.39999997615814, + "memory": 1245184 + }, + { + "initializationMs": 51.30000001192093, + "memory": 1245184 + }, + { + "initializationMs": 44.69999998807907, + "memory": 1245184 + } + ], + "p95": 60.89999997615814 +} diff --git a/docs/rust-engine/website-retired-input.json b/docs/rust-engine/website-retired-input.json new file mode 100644 index 0000000000..ea947985fa --- /dev/null +++ b/docs/rust-engine/website-retired-input.json @@ -0,0 +1,523 @@ +{ + "browser": "153.0.8010.12", + "fixture": "built Start Worker; desktop Chromium; zen; 20 input/restart samples; double-rAF paint; 2000 keyboard characters; forced GC", + "inputToPaint": [ + 113, 65.40000000596046, 74.19999998807907, 54.30000001192093, 51.5, + 57.400000005960464, 34.900000005960464, 45.20000001788139, + 51.80000001192093, 51.30000001192093, 57.29999998211861, 67.5, + 51.900000005960464, 46.89999997615814, 68.7000000178814, 79.7999999821186, + 73.60000002384186, 60.39999997615814, 55.400000005960464, 74 + ], + "restart": [ + 362.70060999999987, 409.529184, 248.14912300000015, 352.91140600000017, + 402.83485199999996, 402.9373940000005, 342.6169469999995, + 364.35750499999995, 361.60447100000056, 371.4272170000004, + 364.5300069999994, 375.3311389999999, 345.8637369999997, 352.5532349999994, + 377.6305850000008, 400.1268600000003, 243.17818700000134, 392.1304279999986, + 369.4064010000002, 341.10226200000034 + ], + "initialHeap": 9200448, + "activeHeap": 13176976, + "resetHeap": 13028736, + "initialWasm": 1310720, + "activeWasm": 5046272, + "resetWasm": 15335424, + "assets": [ + { + "name": "/assets/index-NsmJRIzF.js", + "bytes": 120318, + "transfer": 48329 + }, + { + "name": "/assets/redirect-D6mi4iKQ.js", + "bytes": 848, + "transfer": 784 + }, + { + "name": "/assets/not-found-i5RsCZif.js", + "bytes": 118, + "transfer": 411 + }, + { + "name": "/assets/web-DoCSsFQb.js", + "bytes": 32378, + "transfer": 12617 + }, + { + "name": "/assets/link-BBh-inDq.js", + "bytes": 62968, + "transfer": 22646 + }, + { + "name": "/assets/ClientOnly-BFOvw4I-.js", + "bytes": 315, + "transfer": 528 + }, + { + "name": "/assets/matchContext-CJdPZbyb.js", + "bytes": 106, + "transfer": 410 + }, + { + "name": "/assets/useRouter-Ca7pFXE8.js", + "bytes": 103, + "transfer": 414 + }, + { + "name": "/assets/application-BO7jqupq.js", + "bytes": 1199, + "transfer": 898 + }, + { + "name": "/assets/createServerFn-DlBie0Bv.js", + "bytes": 36231, + "transfer": 11909 + }, + { + "name": "/assets/store-D8POwzGv.js", + "bytes": 6673, + "transfer": 2688 + }, + { + "name": "/assets/build-UMZXoVWB.js", + "bytes": 15058, + "transfer": 5010 + }, + { + "name": "/assets/queryObserver-D957XF2z.js", + "bytes": 20747, + "transfer": 7040 + }, + { + "name": "/assets/preload-helper-BZ1Pz5am.js", + "bytes": 1218, + "transfer": 988 + }, + { + "name": "/assets/zod-messages-CVOW0Ljn.js", + "bytes": 98218, + "transfer": 27639 + }, + { + "name": "/assets/dist-eMfna9Da.js", + "bytes": 2706, + "transfer": 1357 + }, + { + "name": "/assets/skeleton-CWhZcH5J.js", + "bytes": 2536, + "transfer": 1185 + }, + { + "name": "/assets/cn-DLyFV4qv.js", + "bytes": 27805, + "transfer": 9057 + }, + { + "name": "/assets/skeleton-CtcKlTjt.js", + "bytes": 417, + "transfer": 608 + }, + { + "name": "/assets/skeleton-text-CMtoHw-F.js", + "bytes": 390, + "transfer": 591 + }, + { + "name": "/assets/_app.index-3zZgTfvF.js", + "bytes": 2747, + "transfer": 1658 + }, + { + "name": "/assets/_app-B-jaJocn.js", + "bytes": 158, + "transfer": 452 + }, + { + "name": "/assets/skeleton-DZbZqgc0.js", + "bytes": 1235, + "transfer": 910 + }, + { + "name": "/assets/shared-BCIotZRx.js", + "bytes": 8560, + "transfer": 3667 + }, + { + "name": "/assets/browser-runtime.client-DxY0_QFr.js", + "bytes": 13615, + "transfer": 6154 + }, + { + "name": "/assets/personal-bests-CZavyJW4.js", + "bytes": 83241, + "transfer": 26724 + }, + { + "name": "/assets/default-config-BOUeDW9b.js", + "bytes": 3599, + "transfer": 1834 + }, + { + "name": "/assets/test-activity-calendar-DLT37Niu.js", + "bytes": 5567, + "transfer": 2508 + }, + { + "name": "/assets/configs-u1m563Sg.js", + "bytes": 9865, + "transfer": 5176 + }, + { + "name": "/assets/users-BQxs8qhM.js", + "bytes": 9274, + "transfer": 4598 + }, + { + "name": "/assets/_virtual_env-config-Dv0i4k_x.js", + "bytes": 196, + "transfer": 483 + }, + { + "name": "/assets/useRef-9UY2IK1A.js", + "bytes": 98, + "transfer": 415 + }, + { + "name": "/assets/createEvent-DUwnlqx6.js", + "bytes": 313, + "transfer": 528 + }, + { + "name": "/assets/app-bVDKfIJi.js", + "bytes": 54030, + "transfer": 17179 + }, + { + "name": "/assets/test-CoXkNm1T.js", + "bytes": 15382, + "transfer": 6579 + }, + { + "name": "/assets/failure-DwH656nh.js", + "bytes": 644, + "transfer": 647 + }, + { + "name": "/assets/useLocalStorage-CS6HtWhc.js", + "bytes": 2850, + "transfer": 1722 + }, + { + "name": "/assets/effects-D1LbJ-TU.js", + "bytes": 38437, + "transfer": 15621 + }, + { + "name": "/assets/text-EJZLk8Td.js", + "bytes": 840, + "transfer": 755 + }, + { + "name": "/assets/parse-C_h2zvC4.js", + "bytes": 5672, + "transfer": 2774 + }, + { + "name": "/assets/connection-Bz268Fnb.js", + "bytes": 6928, + "transfer": 3160 + }, + { + "name": "/assets/connection-brJSBOCk.js", + "bytes": 2162, + "transfer": 1471 + }, + { + "name": "/assets/build-hTByFGMt.js", + "bytes": 3318, + "transfer": 1634 + }, + { + "name": "/assets/popup.client-DMbzf_vQ.js", + "bytes": 31904, + "transfer": 12211 + }, + { + "name": "/assets/auth-client-l0YKuPAL.js", + "bytes": 1099, + "transfer": 910 + }, + { + "name": "/assets/auth-CzxF2Cbs.js", + "bytes": 6921, + "transfer": 3216 + }, + { + "name": "/assets/date-and-time-7jSqKTxA.js", + "bytes": 964, + "transfer": 796 + }, + { + "name": "/assets/settled.client-Bl-r4WIX.js", + "bytes": 530, + "transfer": 643 + }, + { + "name": "/assets/query-cache.client-DGuNxoVI.js", + "bytes": 3064, + "transfer": 1729 + }, + { + "name": "/assets/settled.client-CGQVU2CB.js", + "bytes": 349, + "transfer": 557 + }, + { + "name": "/assets/update-safety-CL2VCWi9.js", + "bytes": 140, + "transfer": 443 + }, + { + "name": "/assets/cookies-9RaaeYbH.js", + "bytes": 698, + "transfer": 731 + }, + { + "name": "/assets/glarses-mode-B94l9V42.js", + "bytes": 285, + "transfer": 522 + }, + { + "name": "/assets/fonts.client-BvP2fVaE.js", + "bytes": 840, + "transfer": 817 + }, + { + "name": "/assets/chrome.client-xZx4Mqh2.js", + "bytes": 41670, + "transfer": 14489 + }, + { + "name": "/assets/rolldown-runtime-Dd_uD5pT.js", + "bytes": 1106, + "transfer": 927 + }, + { + "name": "/assets/hotkeys-Dje90egG.js", + "bytes": 17130, + "transfer": 6675 + }, + { + "name": "/assets/Kbd-DPDwH4vf.js", + "bytes": 1953, + "transfer": 1436 + }, + { + "name": "/assets/overlay-visibility-CHOMN59j.js", + "bytes": 181, + "transfer": 461 + }, + { + "name": "/assets/UpdateNameModal-CSJBRW1n.js", + "bytes": 6846, + "transfer": 3188 + }, + { + "name": "/assets/simple-modal-BRmIz2Q2.js", + "bytes": 255, + "transfer": 502 + }, + { + "name": "/assets/utils-oI_LPHw2.js", + "bytes": 638, + "transfer": 666 + }, + { + "name": "/assets/breakpoints-D9JC0XUy.js", + "bytes": 111, + "transfer": 409 + }, + { + "name": "/assets/modifier-keys-CBdW-khj.js", + "bytes": 165, + "transfer": 437 + }, + { + "name": "/assets/navigate-H3LNuvCa.js", + "bytes": 268, + "transfer": 469 + }, + { + "name": "/assets/anime-CTEXKIsT.js", + "bytes": 5568, + "transfer": 2629 + }, + { + "name": "/assets/levels-DY_hXd5y.js", + "bytes": 414, + "transfer": 593 + }, + { + "name": "/assets/page.client-CX552A7f.js", + "bytes": 77241, + "transfer": 24036 + }, + { + "name": "/assets/modifiers-GL8zCR8g.js", + "bytes": 1872, + "transfer": 1179 + }, + { + "name": "/assets/mode-BrULBDpK.js", + "bytes": 258, + "transfer": 475 + }, + { + "name": "/assets/quote-rate-DSFbsSZN.js", + "bytes": 49881, + "transfer": 17155 + }, + { + "name": "/assets/test-words-F4oZBILX.js", + "bytes": 1064, + "transfer": 853 + }, + { + "name": "/assets/content.client-CODE3MIe.js", + "bytes": 6793, + "transfer": 3274 + }, + { + "name": "/assets/test-logic-CZwXGups.js", + "bytes": 15495, + "transfer": 6613 + }, + { + "name": "/assets/practise-words-l1NyGBlp.js", + "bytes": 1206, + "transfer": 969 + }, + { + "name": "/assets/format-CrpNKFdj.js", + "bytes": 187, + "transfer": 462 + }, + { + "name": "/assets/quotes-controller-yZxt71fI.js", + "bytes": 1124, + "transfer": 901 + }, + { + "name": "/assets/open-D8ucHBZR.js", + "bytes": 253, + "transfer": 494 + }, + { + "name": "/assets/lz-ts-BjRkF-Z2.js", + "bytes": 3789, + "transfer": 1602 + }, + { + "name": "/assets/challenge-controller-CyolkCtO.js", + "bytes": 3977, + "transfer": 1883 + }, + { + "name": "/assets/browser.client-D-ggqEX5.js", + "bytes": 13225, + "transfer": 3748 + }, + { + "name": "/assets/engine_bg-DnSypyoj.wasm", + "bytes": 765852, + "transfer": 303919 + }, + { + "name": "/assets/Result-CX9MsWhB.js", + "bytes": 36169, + "transfer": 12577 + }, + { + "name": "/assets/tags-D-mDB-TG.js", + "bytes": 6061, + "transfer": 2484 + }, + { + "name": "/assets/reader-Bt26aDGi.js", + "bytes": 336480, + "transfer": 92657 + }, + { + "name": "/assets/typing-speed-units-CKRVpsSZ.js", + "bytes": 37, + "transfer": 357 + }, + { + "name": "/assets/UserFlags-DEjlWAIi.js", + "bytes": 994, + "transfer": 789 + }, + { + "name": "/assets/edit-result-tags-B6_z7K_Q.js", + "bytes": 294, + "transfer": 534 + }, + { + "name": "/assets/result-CmUxryIK.js", + "bytes": 25338, + "transfer": 9730 + }, + { + "name": "/assets/today-tracker-CjXJJMlE.js", + "bytes": 2353, + "transfer": 1486 + }, + { + "name": "/assets/quote-report-lQymxy1o.js", + "bytes": 163, + "transfer": 455 + }, + { + "name": "/assets/test-screenshot-Cf1RMK28.js", + "bytes": 4903, + "transfer": 2610 + }, + { + "name": "/assets/completion.client-D8ALtp9S.js", + "bytes": 5418, + "transfer": 2967 + }, + { + "name": "/assets/result-saving-D9WDlUdB.js", + "bytes": 5023, + "transfer": 2578 + }, + { + "name": "/assets/ResultChart-NB-CUlvC.js", + "bytes": 6022, + "transfer": 2755 + }, + { + "name": "/assets/ChartJs-9u_ZWnCG.js", + "bytes": 74721, + "transfer": 22587 + }, + { + "name": "/assets/compareAsc-B_HNaHZS.js", + "bytes": 105, + "transfer": 422 + }, + { + "name": "/assets/endOfWeek-DjLIv9dA.js", + "bytes": 1860, + "transfer": 1086 + }, + { + "name": "/assets/chart-Bx03QiIs.js", + "bytes": 177979, + "transfer": 61565 + } + ], + "errors": [] +} diff --git a/docs/rust-engine/website-retired-long.json b/docs/rust-engine/website-retired-long.json new file mode 100644 index 0000000000..9dfdd2927e --- /dev/null +++ b/docs/rust-engine/website-retired-long.json @@ -0,0 +1,236 @@ +{ + "browser": "153.0.8010.12", + "fixture": "built Cloudflare Worker; 20000 UTF-16 units; live/minimum rules; 100 owner replacements of 2000 units", + "hostQueries": { + "compositionReady": true, + "metadata": { + "allWordsGenerated": false, + "hasTab": true, + "hasNewline": true + }, + "burst": 240, + "canDelete": false, + "timeLimitSeconds": null + }, + "initializationMs": 20.400000005960464, + "initial": 1310720, + "memory": [ + 3604480, 5963776, 8323072, 10616832, 13238272, 15269888, 17432576, 19464192, + 23134208, 25165824 + ], + "costs": [ + 0.023100000008940698, 0.01770000000298023, 0.01680000001192093, + 0.017349999994039536, 0.01770000000298023, 0.017900000005960464, 0.01875, + 0.020099999994039535, 0.02075, 0.02145000000298023 + ], + "events": 20200, + "active": 81264640, + "afterReset": 81264640, + "resetStats": { + "chars": { + "allCorrect": 0, + "correctWord": 0, + "incorrect": 0, + "extra": 0, + "missed": 0 + }, + "wpm": 0, + "raw": 0, + "acc": 100, + "seconds": 0, + "durationMs": 0, + "timerStartMs": null, + "timeLimitSeconds": null, + "msSinceLastInput": null + }, + "restarts": [ + 81264640, 81264640, 81264640, 81264640, 81264640, 81264640, 81264640, + 81264640, 81264640, 81264640, 81264640, 81264640, 81264640, 81264640, + 81264640, 81264640, 81264640, 81264640, 81264640, 81264640, 81264640, + 81264640, 81264640, 81264640, 81264640, 81264640, 81264640, 81264640, + 81264640, 81264640, 81264640, 81264640, 81264640, 81264640, 81264640, + 81264640, 81264640, 81264640, 81264640, 81264640, 81264640, 81264640, + 81264640, 81264640, 81264640, 81264640, 81264640, 81264640, 81264640, + 81264640, 81264640, 81264640, 81264640, 81264640, 81264640, 81264640, + 81264640, 81264640, 81264640, 81264640, 81264640, 81264640, 81264640, + 81264640, 81264640, 81264640, 81264640, 81264640, 81264640, 81264640, + 81264640, 81264640, 81264640, 81264640, 81264640, 81264640, 81264640, + 81264640, 81264640, 81264640, 81264640, 81264640, 81264640, 81264640, + 81264640, 81264640, 81264640, 81264640, 81264640, 81264640, 81264640, + 81264640, 81264640, 81264640, 81264640, 81264640, 81264640, 81264640, + 81264640, 81264640 + ], + "live": [ + { + "chars": { + "allCorrect": 2000, + "correctWord": 2000, + "incorrect": 0, + "extra": 0, + "missed": 0 + }, + "wpm": 1200, + "raw": 1200, + "acc": 100, + "seconds": 20, + "durationMs": 20000, + "timerStartMs": 0, + "timeLimitSeconds": null, + "msSinceLastInput": 0 + }, + { + "chars": { + "allCorrect": 4000, + "correctWord": 4000, + "incorrect": 0, + "extra": 0, + "missed": 0 + }, + "wpm": 1200, + "raw": 1200, + "acc": 100, + "seconds": 40, + "durationMs": 40000, + "timerStartMs": 0, + "timeLimitSeconds": null, + "msSinceLastInput": 0 + }, + { + "chars": { + "allCorrect": 6000, + "correctWord": 6000, + "incorrect": 0, + "extra": 0, + "missed": 0 + }, + "wpm": 1200, + "raw": 1200, + "acc": 100, + "seconds": 60, + "durationMs": 60000, + "timerStartMs": 0, + "timeLimitSeconds": null, + "msSinceLastInput": 0 + }, + { + "chars": { + "allCorrect": 8000, + "correctWord": 8000, + "incorrect": 0, + "extra": 0, + "missed": 0 + }, + "wpm": 1200, + "raw": 1200, + "acc": 100, + "seconds": 80, + "durationMs": 80000, + "timerStartMs": 0, + "timeLimitSeconds": null, + "msSinceLastInput": 0 + }, + { + "chars": { + "allCorrect": 10000, + "correctWord": 10000, + "incorrect": 0, + "extra": 0, + "missed": 0 + }, + "wpm": 1200, + "raw": 1200, + "acc": 100, + "seconds": 100, + "durationMs": 100000, + "timerStartMs": 0, + "timeLimitSeconds": null, + "msSinceLastInput": 0 + }, + { + "chars": { + "allCorrect": 12000, + "correctWord": 12000, + "incorrect": 0, + "extra": 0, + "missed": 0 + }, + "wpm": 1200, + "raw": 1200, + "acc": 100, + "seconds": 120, + "durationMs": 120000, + "timerStartMs": 0, + "timeLimitSeconds": null, + "msSinceLastInput": 0 + }, + { + "chars": { + "allCorrect": 14000, + "correctWord": 14000, + "incorrect": 0, + "extra": 0, + "missed": 0 + }, + "wpm": 1200, + "raw": 1200, + "acc": 100, + "seconds": 140, + "durationMs": 140000, + "timerStartMs": 0, + "timeLimitSeconds": null, + "msSinceLastInput": 0 + }, + { + "chars": { + "allCorrect": 16000, + "correctWord": 16000, + "incorrect": 0, + "extra": 0, + "missed": 0 + }, + "wpm": 1200, + "raw": 1200, + "acc": 100, + "seconds": 160, + "durationMs": 160000, + "timerStartMs": 0, + "timeLimitSeconds": null, + "msSinceLastInput": 0 + }, + { + "chars": { + "allCorrect": 18000, + "correctWord": 18000, + "incorrect": 0, + "extra": 0, + "missed": 0 + }, + "wpm": 1200, + "raw": 1200, + "acc": 100, + "seconds": 180, + "durationMs": 180000, + "timerStartMs": 0, + "timeLimitSeconds": null, + "msSinceLastInput": 0 + }, + { + "chars": { + "allCorrect": 20000, + "correctWord": 20000, + "incorrect": 0, + "extra": 0, + "missed": 0 + }, + "wpm": 1200, + "raw": 1200, + "acc": 100, + "seconds": 200, + "durationMs": 200000, + "timerStartMs": 0, + "timeLimitSeconds": null, + "msSinceLastInput": 0 + } + ], + "invalid": 3 +} diff --git a/docs/rust-engine/website-retired-reset.json b/docs/rust-engine/website-retired-reset.json new file mode 100644 index 0000000000..1679bb0c9f --- /dev/null +++ b/docs/rust-engine/website-retired-reset.json @@ -0,0 +1,74 @@ +{ + "browser": "153.0.8010.12", + "fixture": "built Cloudflare Worker; frozen 2000-unit reset workload; 100 replacements", + "hostQueries": { + "compositionReady": true, + "metadata": { + "allWordsGenerated": false, + "hasTab": true, + "hasNewline": true + }, + "burst": 240, + "canDelete": false, + "timeLimitSeconds": null + }, + "initializationMs": 21.099999994039536, + "initial": 1310720, + "memory": [3604480], + "costs": [0.024700000002980234], + "events": 2020, + "active": 9240576, + "afterReset": 9240576, + "resetStats": { + "chars": { + "allCorrect": 0, + "correctWord": 0, + "incorrect": 0, + "extra": 0, + "missed": 0 + }, + "wpm": 0, + "raw": 0, + "acc": 100, + "seconds": 0, + "durationMs": 0, + "timerStartMs": null, + "timeLimitSeconds": null, + "msSinceLastInput": null + }, + "restarts": [ + 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, + 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, + 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, + 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, + 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, + 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, + 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, + 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, + 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, + 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, + 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, + 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, 9240576, + 9240576, 9240576, 9240576, 9240576 + ], + "live": [ + { + "chars": { + "allCorrect": 2000, + "correctWord": 2000, + "incorrect": 0, + "extra": 0, + "missed": 0 + }, + "wpm": 1200, + "raw": 1200, + "acc": 100, + "seconds": 20, + "durationMs": 20000, + "timerStartMs": 0, + "timeLimitSeconds": null, + "msSinceLastInput": 0 + } + ], + "invalid": 3 +} diff --git a/docs/rust-engine/website-retired-soak.json b/docs/rust-engine/website-retired-soak.json new file mode 100644 index 0000000000..d81dc5ca3f --- /dev/null +++ b/docs/rust-engine/website-retired-soak.json @@ -0,0 +1,18 @@ +{ + "fixture": "3 real-keyboard 2000-unit zen sessions; restart and forced GC", + "samples": [ + { + "heap": 11903840, + "wasm": 15335424 + }, + { + "heap": 12071364, + "wasm": 15335424 + }, + { + "heap": 12183256, + "wasm": 15335424 + } + ], + "errors": [] +} diff --git a/docs/tanstack-start/release.md b/docs/tanstack-start/release.md index 272c380961..10659a58c8 100644 --- a/docs/tanstack-start/release.md +++ b/docs/tanstack-start/release.md @@ -55,3 +55,38 @@ Historical legacy/Start rollback rehearsals and deployment observations are in the [archived release record](../archive/tanstack-start/release.md) and [stage reports](../archive/tanstack-start/README.md). They do not close the compatibility window or certify the currently hosted deployment. + +## Rust engine cutover + +The website builds one Rust engine through browser-only Wasm. No permanent dual +engine fallback remains. Native `typing-engine` code, generated bindings, browser +glue and `.wasm` are a matched unit. Bootstrap runs before Vite in development, +production/staging and CI; the production workflow installs the pinned toolchain. + +Wasm is content-addressed under `/assets/`, served as `application/wasm` with +immutable caching and the existing `wasm-unsafe-eval` CSP permission. Preserve +previous hashed Wasm **and** its importing JS graph. The public integrity index +includes both; failed/colliding captures stop the build. The service worker +precaches engine/glue for cached guest typing; it never caches private responses. +A missing or interrupted first download leaves typing disabled with Restart retry. + +The original source anchor is +`7b1ae05f204be53b5d9f2714f21c6eea08b25935`; completed Stage 1 +`dc843955a` also retains the original website runtime. Immutable references and +source provenance remain in `packages/typing-fixtures`. These Git anchors preserve +source; they are **not** claims about the currently hosted Worker version or an +archived deployed artifact. + +Before the authorized production cutover, retain the currently deployed matched +Worker/assets artifact and record its Cloudflare version using the operations +guide above. Rehearse on the authorized staging deployment, including stale tabs, +settings, authenticated save retry, IME, historical replay and engine download +failure. This implementation has local workerd/matched-build update/rollback +evidence; it does not assert hosted staging/production verification. Rollback uses +the retained deployment artifact, without restoring D1 or rebuilding its client. +New and old tabs keep their own engine version throughout a test. Idle update +activation and pending-save protection are unchanged. + +Frozen performance budgets and raw evidence live in `docs/rust-engine/`; keep the +original baseline files unchanged. `vp run engine:parity` compares native and +built-browser Wasm directly with the frozen reference outputs after retirement. diff --git a/frontend/__tests__/__harness__/native-engine.ts b/frontend/__tests__/__harness__/native-engine.ts new file mode 100644 index 0000000000..793ac9eeb2 --- /dev/null +++ b/frontend/__tests__/__harness__/native-engine.ts @@ -0,0 +1,22 @@ +import { resolve } from "node:path"; +import { readFileSync } from "node:fs"; +import { initSync } from "../../../packages/typing-engine/generated/engine.js"; +import { BrowserSession } from "@oxytype/typing-engine/browser.client"; +import { encodeText } from "@oxytype/typing-engine/text"; +import { projectEngineConfig } from "@oxytype/typing-engine/config"; + +// Exercise the same generated/optimized Wasm and SDK used by the website. +initSync({ + module: readFileSync( + resolve( + import.meta.dirname, + "../../../packages/typing-engine/generated/engine_bg.wasm", + ), + ), +}); +export function nativeSession( + config: Parameters[0], + words: string[], +): BrowserSession { + return new BrowserSession(projectEngineConfig(config), words.map(encodeText)); +} diff --git a/frontend/__tests__/app/navigation-guard.spec.ts b/frontend/__tests__/app/navigation-guard.spec.ts index 5dc397a859..c955350ad6 100644 --- a/frontend/__tests__/app/navigation-guard.spec.ts +++ b/frontend/__tests__/app/navigation-guard.spec.ts @@ -13,8 +13,8 @@ vi.mock("../../src/ts/states/test", () => ({ isResultCalculating: () => state.calculating, isTestActive: () => state.active, })); -vi.mock("../../src/features/typing/runtime/funbox/list", () => ({ - isFunboxActive: () => state.noQuit, +vi.mock("../../src/features/typing/runtime/session.client", () => ({ + isNoQuitActive: () => state.noQuit, })); vi.mock("../../src/ts/states/notifications", () => ({ showNoticeNotification: state.notice, diff --git a/frontend/__tests__/app/navigation.spec.tsx b/frontend/__tests__/app/navigation.spec.tsx index 6e5e71c9e0..f91dfe26f9 100644 --- a/frontend/__tests__/app/navigation.spec.tsx +++ b/frontend/__tests__/app/navigation.spec.tsx @@ -33,8 +33,8 @@ vi.mock("../../src/ts/states/test", () => ({ isResultCalculating: () => state.calculating, isTestActive: () => state.active, })); -vi.mock("../../src/features/typing/runtime/funbox/list", () => ({ - isFunboxActive: () => state.noQuit, +vi.mock("../../src/features/typing/runtime/session.client", () => ({ + isNoQuitActive: () => state.noQuit, })); vi.mock("../../src/ts/states/notifications", () => ({ showNoticeNotification: state.notice, diff --git a/frontend/__tests__/app/typing-owner.spec.ts b/frontend/__tests__/app/typing-owner.spec.ts index 9e8781ad02..a75190d027 100644 --- a/frontend/__tests__/app/typing-owner.spec.ts +++ b/frontend/__tests__/app/typing-owner.spec.ts @@ -1,4 +1,4 @@ -import { expect, it } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; import { mountApplication } from "../../src/app/application"; import { @@ -7,6 +7,12 @@ import { } from "../__harness__/mock-application"; releaseTestApplication(); +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { getBrowserEngine } from "../../src/features/typing/engine.client"; +import { projectEngineConfig } from "@oxytype/typing-engine/config"; +import { getMutableConfig } from "../../src/ts/config/store"; +import { encodeText } from "@oxytype/typing-engine/text"; it("imports typing adapters without a mounted app, then recreates and stops each session on remount", async () => { const { getTestSession } = @@ -16,7 +22,48 @@ it("imports typing adapters without a mounted app, then recreates and stops each await import("../../src/features/typing/runtime/test-timer"); const first = createTestApplication(); const release = mountApplication(first.application); + vi.stubGlobal( + "fetch", + async () => + new Response( + readFileSync( + resolve( + import.meta.dirname, + "../../../packages/typing-engine/generated/engine_bg.wasm", + ), + ), + { headers: { "content-type": "application/wasm" } }, + ), + ); + vi.spyOn(WebAssembly, "compileStreaming").mockImplementation( + async (response) => + WebAssembly.compile(await (await response).arrayBuffer()), + ); + const engine = getBrowserEngine(); + await engine.load(); + engine.beginReplacement().prepare({ + config: projectEngineConfig(getMutableConfig()), + language: { + name: "english", + words: [encodeText("hello")], + bcp47: null, + rightToLeft: null, + joiningScript: false, + originalPunctuation: false, + noLazyMode: false, + additionalAccents: [], + }, + custom: null, + quotes: null, + polyglot: [], + selectedQuoteId: 1, + favoriteQuoteIds: [], + sections: [], + random: { kind: "seeded", state: 1 }, + }); + engine.createPreparedSession(); const session = getTestSession(); + session.attach(); session.start(0); words.push("hello ", 0); expect(session.isActive()).toBe(true); @@ -31,4 +78,6 @@ it("imports typing adapters without a mounted app, then recreates and stops each expect(words.length).toBe(0); next.dispose(); releaseNext(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); }); diff --git a/frontend/__tests__/components/modals/LastSignedOutResultModal.spec.tsx b/frontend/__tests__/components/modals/LastSignedOutResultModal.spec.tsx index 25d855217e..3aa7cfec2d 100644 --- a/frontend/__tests__/components/modals/LastSignedOutResultModal.spec.tsx +++ b/frontend/__tests__/components/modals/LastSignedOutResultModal.spec.tsx @@ -42,7 +42,7 @@ vi.mock("../../../src/ts/components/common/AnimatedModal", () => ({ AnimatedModal: (props: ParentProps) =>
{props.children}
, })); -import fixture from "../../../../packages/typing-core/__fixtures__/keystrokes/words-10-clean.json"; +import fixture from "../../../../packages/typing-fixtures/reference/keystrokes/words-10-clean.json"; import { LastSignedOutResultModal, updateUidAndHash, diff --git a/frontend/__tests__/components/modals/event-log-viewer/event-log.spec.ts b/frontend/__tests__/components/modals/event-log-viewer/event-log.spec.ts index 33d7022a8c..3e9a52e434 100644 --- a/frontend/__tests__/components/modals/event-log-viewer/event-log.spec.ts +++ b/frontend/__tests__/components/modals/event-log-viewer/event-log.spec.ts @@ -2,39 +2,18 @@ import { describe, expect, it } from "vite-plus/test"; import { getCurrentEventIndex, - getCurrentWordIndex, getMaxTestMs, getNextEventMs, getPreviousEventMs, - inputsPerWord, parseContext, visualizeWhitespace, } from "../../../../src/ts/components/modals/event-log-viewer/event-log"; -import type { TestEventNoMs } from "@oxytype/typing-core/events/types"; +import type { TestEventNoMs } from "@oxytype/web-shared/events/types"; function keydown(testMs: number, code = "KeyA"): TestEventNoMs { return { type: "keydown", testMs, data: { code } } as TestEventNoMs; } -function input( - testMs: number, - wordIndex: number, - inputValue: string, -): TestEventNoMs { - return { - type: "input", - testMs, - data: { - inputType: "insertText", - data: inputValue.slice(-1), - correct: true, - charIndex: inputValue.length - 1, - wordIndex, - inputValue, - }, - }; -} - const validLog = { version: 1, events: [], @@ -94,22 +73,6 @@ describe("visualizeWhitespace", () => { }); }); -describe("inputsPerWord", () => { - it("returns the last input value per word", () => { - const events = [ - keydown(0), - input(1, 0, "h"), - input(2, 0, "hi"), - input(3, 1, "y"), - ]; - expect(inputsPerWord(events, 3)).toEqual(["hi", "y", ""]); - }); - - it("returns empty strings without input", () => { - expect(inputsPerWord([keydown(0)], 2)).toEqual(["", ""]); - }); -}); - describe("getMaxTestMs", () => { it("returns the ceiling of the latest testMs", () => { expect(getMaxTestMs([keydown(10.2), keydown(5)])).toBe(11); @@ -120,18 +83,6 @@ describe("getMaxTestMs", () => { }); }); -describe("getCurrentWordIndex", () => { - it("returns the word of the last input", () => { - expect( - getCurrentWordIndex([input(0, 0, "a"), input(1, 2, "b"), keydown(2)]), - ).toBe(2); - }); - - it("returns -1 without input", () => { - expect(getCurrentWordIndex([keydown(0)])).toBe(-1); - }); -}); - describe("getCurrentEventIndex", () => { const events = [keydown(0), keydown(10), keydown(10), keydown(20)]; diff --git a/frontend/__tests__/components/modals/event-log-viewer/scatter-points.spec.ts b/frontend/__tests__/components/modals/event-log-viewer/scatter-points.spec.ts index c6d83de847..f7b303982b 100644 --- a/frontend/__tests__/components/modals/event-log-viewer/scatter-points.spec.ts +++ b/frontend/__tests__/components/modals/event-log-viewer/scatter-points.spec.ts @@ -12,7 +12,7 @@ import { keyLabel, percentileOf, } from "../../../../src/ts/components/modals/event-log-viewer/scatter-points"; -import type { TestEventNoMs } from "@oxytype/typing-core/events/types"; +import type { TestEventNoMs } from "@oxytype/web-shared/events/types"; function keydown(testMs: number, code: string): TestEventNoMs { return { type: "keydown", testMs, data: { code } } as TestEventNoMs; diff --git a/frontend/__tests__/components/modals/event-log-viewer/timeline-lanes.spec.ts b/frontend/__tests__/components/modals/event-log-viewer/timeline-lanes.spec.ts index 2abae679fb..0f2c9ea96c 100644 --- a/frontend/__tests__/components/modals/event-log-viewer/timeline-lanes.spec.ts +++ b/frontend/__tests__/components/modals/event-log-viewer/timeline-lanes.spec.ts @@ -15,7 +15,7 @@ import { EVENT_TYPES } from "../../../../src/ts/states/event-log-viewer"; import type { TestEventNoMs, TestEventType, -} from "@oxytype/typing-core/events/types"; +} from "@oxytype/web-shared/events/types"; function key( type: "keydown" | "keyup", diff --git a/frontend/__tests__/components/modals/event-log-viewer/video-sync.spec.ts b/frontend/__tests__/components/modals/event-log-viewer/video-sync.spec.ts index 57cf9cc3e7..2fdb718279 100644 --- a/frontend/__tests__/components/modals/event-log-viewer/video-sync.spec.ts +++ b/frontend/__tests__/components/modals/event-log-viewer/video-sync.spec.ts @@ -18,7 +18,7 @@ import { videoMsToTestMs, } from "../../../../src/ts/components/modals/event-log-viewer/video-sync"; import type { Mark } from "../../../../src/ts/states/event-log-viewer"; -import type { TestEventNoMs } from "@oxytype/typing-core/events/types"; +import type { TestEventNoMs } from "@oxytype/web-shared/events/types"; function event(testMs: number): TestEventNoMs { return { type: "keydown", testMs, data: { code: "KeyA" } }; diff --git a/frontend/__tests__/components/pages/test/EndTestButton.spec.tsx b/frontend/__tests__/components/pages/test/EndTestButton.spec.tsx index 5a9ebdc4fa..8eed8d99a9 100644 --- a/frontend/__tests__/components/pages/test/EndTestButton.spec.tsx +++ b/frontend/__tests__/components/pages/test/EndTestButton.spec.tsx @@ -1,3 +1,5 @@ +import "../../../__harness__/native-engine"; +import { evaluatePolicy } from "@oxytype/typing-engine/browser.client"; import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"; import { afterEach, @@ -76,3 +78,10 @@ describe("EndTestButton", () => { ).toBeInTheDocument(); }); }); + +vi.mock("../../../../src/features/typing/engine.client", () => ({ + getBrowserEngine: () => ({ + status: () => ({ state: "ready" }), + api: () => ({ policy: evaluatePolicy }), + }), +})); diff --git a/frontend/__tests__/components/pages/test/result/ResultChart.spec.tsx b/frontend/__tests__/components/pages/test/result/ResultChart.spec.tsx index 2546a7666a..8a76fcb93a 100644 --- a/frontend/__tests__/components/pages/test/result/ResultChart.spec.tsx +++ b/frontend/__tests__/components/pages/test/result/ResultChart.spec.tsx @@ -13,7 +13,7 @@ const mocks = vi.hoisted(() => ({ vi.mock("../../../../../src/features/typing/runtime/result", () => ({ toggleResultChartLegend: mocks.toggleResultChartLegend, })); -vi.mock("@oxytype/typing-core/events/stats", () => ({ +vi.mock("../../../../../src/features/typing/analysis.client", () => ({ getWordIndexesForSecond: mocks.getWordIndexesForSecond, })); vi.mock("../../../../../src/ts/states/test", () => ({ diff --git a/frontend/__tests__/components/pages/test/result/ResultReplay.spec.tsx b/frontend/__tests__/components/pages/test/result/ResultReplay.spec.tsx index 541f74dc98..31637f1793 100644 --- a/frontend/__tests__/components/pages/test/result/ResultReplay.spec.tsx +++ b/frontend/__tests__/components/pages/test/result/ResultReplay.spec.tsx @@ -1,4 +1,4 @@ -import type { EventLog } from "@oxytype/typing-core/events/types"; +import type { EventLog } from "@oxytype/web-shared/events/types"; import { cleanup, fireEvent, render } from "@solidjs/testing-library"; import { @@ -28,33 +28,47 @@ vi.mock("../../../../../src/ts/controllers/sound-controller", () => ({ playClick: vi.fn(), playError: vi.fn(), })); -vi.mock("../../../../../src/features/typing/runtime/events/data", async () => { - const { getReplaySteps } = await import("@oxytype/typing-core/replay"); - const targetWords = ["ab ", "cd"]; +import "../../../../__harness__/native-engine"; +import { + analyzeLog, + BrowserReplay, +} from "@oxytype/typing-engine/browser.client"; +import { toEngineLog } from "@oxytype/typing-engine/text"; +function log(): EventLog { return { - buildEventLog: () => [], - getTestSession: (() => { - const session = { - getReplay: () => ({ - words: targetWords, - steps: getReplaySteps( - { context: { targetWords }, events: mocks.events } as EventLog, - false, - ), - }), - }; - return () => session; - })(), + version: 1, + context: { + targetWords: ["ab ", "cd"], + mode: "words", + mode2: "2", + koreanStatus: false, + bailedOut: false, + }, + events: mocks.events.map((event) => ({ + ...event, + data: { + ...event.data, + charIndex: Math.max(0, (event.data.inputValue?.length ?? 0) - 1), + }, + })) as EventLog["events"], }; -}); -vi.mock("@oxytype/typing-core/events/helpers", async (importOriginal) => ({ - ...(await importOriginal< - typeof import("@oxytype/typing-core/events/helpers") - >()), - getInputFromDom: () => mocks.inputForWord, +} +vi.mock("../../../../../src/features/typing/runtime/events/data", () => ({ + buildEventLog: log, + getTestSession: () => ({ + getReplay: () => + analyzeLog(toEngineLog(log()), { op: "replay", zen: false }), + }), +})); +vi.mock("../../../../../src/features/typing/engine.client", () => ({ + getBrowserEngine: () => ({ + api: () => ({ + createReplay: (data: Parameters[0], zen: boolean) => + new BrowserReplay(data, zen), + }), + }), })); -vi.mock("@oxytype/typing-core/events/stats", () => ({ - getInputHistory: () => [], +vi.mock("../../../../../src/features/typing/analysis.client", () => ({ getWpmHistory: () => [60, 70], })); @@ -71,11 +85,18 @@ function input(testMs: number, data: Input): (typeof mocks.events)[number] { // a, x (wrong), backspace, b, space, c, d const events = [ - input(0, { wordIndex: 0, inputType: "insertText", data: "a", correct: true }), + input(0, { + wordIndex: 0, + inputType: "insertText", + data: "a", + inputValue: "a", + correct: true, + }), input(100, { wordIndex: 0, inputType: "insertText", data: "x", + inputValue: "ax", correct: false, }), input(200, { @@ -87,24 +108,28 @@ const events = [ wordIndex: 0, inputType: "insertText", data: "b", + inputValue: "ab", correct: true, }), input(400, { wordIndex: 0, inputType: "insertText", data: " ", + inputValue: "ab ", correct: true, }), input(1200, { wordIndex: 1, inputType: "insertText", data: "c", + inputValue: "c", correct: true, }), input(1300, { wordIndex: 1, inputType: "insertText", data: "d", + inputValue: "cd", correct: true, }), ]; @@ -204,30 +229,35 @@ describe("ResultReplay", () => { wordIndex: 0, inputType: "insertText", data: "a", + inputValue: "a", correct: true, }), input(10, { wordIndex: 0, inputType: "insertText", data: "b", + inputValue: "ab", correct: true, }), input(20, { wordIndex: 0, inputType: "insertText", data: " ", + inputValue: "ab ", correct: true, }), input(30, { wordIndex: 0, inputType: "insertText", data: "z", + inputValue: "ab z", correct: false, }), input(40, { wordIndex: 1, inputType: "insertText", data: "c", + inputValue: "c", correct: true, }), ]; diff --git a/frontend/__tests__/components/pages/test/result/ResultWordsHistory.spec.tsx b/frontend/__tests__/components/pages/test/result/ResultWordsHistory.spec.tsx index 62cd528214..1c486960ca 100644 --- a/frontend/__tests__/components/pages/test/result/ResultWordsHistory.spec.tsx +++ b/frontend/__tests__/components/pages/test/result/ResultWordsHistory.spec.tsx @@ -29,7 +29,7 @@ vi.mock("../../../../../src/ts/states/test", () => ({ getResultVisible: () => true, getLastEventLog: () => [], })); -vi.mock("@oxytype/typing-core/events/stats", () => ({ +vi.mock("../../../../../src/features/typing/analysis.client", () => ({ getInputHistory: () => mocks.inputHistory, getMissedWords: () => ({ hello: 1 }), getWordBurstHistory: () => mocks.burstHistory, diff --git a/frontend/__tests__/features/typing/completion.spec.ts b/frontend/__tests__/features/typing/completion.spec.ts index bf9e3d715d..e98db272d2 100644 --- a/frontend/__tests__/features/typing/completion.spec.ts +++ b/frontend/__tests__/features/typing/completion.spec.ts @@ -18,6 +18,7 @@ vi.mock("../../../src/ts/states/test-dom", () => ({ })); vi.mock("../../../src/ts/states/test", () => ({ isTestActive: () => true, + getBailedOut: () => false, isResultCalculating: () => mocks.calculating, setResultCalculating: (value: boolean) => { mocks.calculating = value; diff --git a/frontend/__tests__/features/typing/content-assets.spec.ts b/frontend/__tests__/features/typing/content-assets.spec.ts new file mode 100644 index 0000000000..b6db8341c3 --- /dev/null +++ b/frontend/__tests__/features/typing/content-assets.spec.ts @@ -0,0 +1,102 @@ +import { afterEach, expect, it, vi } from "vite-plus/test"; +import { decodeText } from "@oxytype/typing-engine/text"; +import { + engineLanguage, + engineQuotes, + fetchEngineSource, +} from "../../../src/features/typing/runtime/content-assets.client"; +afterEach(() => vi.unstubAllGlobals()); + +it("keeps raw Unicode words, accents, quote whitespace and language metadata for Rust", () => { + const language = engineLanguage({ + name: "english", + words: ["\ud800", "Å… 😀"], + bcp47: "en", + originalPunctuation: true, + additionalAccents: [["å", "a"]], + rightToLeft: false, + }); + expect(language.words.map(decodeText)).toEqual(["\ud800", "Å… 😀"]); + expect( + language.additionalAccents.map(([a, b]) => [decodeText(a), decodeText(b)]), + ).toEqual([["å", "a"]]); + expect(language.originalPunctuation).toBe(true); + const quotes = engineQuotes({ + language: "english", + groups: [ + [0, 10], + [11, 20], + [21, 30], + [31, 100], + ], + quotes: [{ id: 1, text: " line…\n next ", source: "Source", length: 16 }], + }); + expect(decodeText(quotes.quotes[0]?.text ?? [])).toBe(" line…\n next "); + expect(quotes.quotes[0]?.britishText).toBeUndefined(); + expect(quotes.quotes[0]?.textSplit).toEqual([]); +}); + +it("extracts Wikipedia HTML while leaving citations, typography and Unicode cleanup to Rust", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ title: "Article", pageid: 1 })), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + query: { + pages: { "1": { extract: "

“Å” [1]

next 😀

" } }, + }, + }), + ), + ); + vi.stubGlobal("fetch", fetch); + const signal = new AbortController().signal; + const source = await fetchEngineSource( + { id: 1, generation: 1, kind: "wikipedia", language: "english" }, + { name: "english", words: ["a"], bcp47: "en-US" }, + signal, + ); + expect(source.kind).toBe("article"); + if (source.kind !== "article") throw new Error("Wrong source"); + expect(decodeText(source.plainText)).toBe("“Å” [1] next 😀"); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch.mock.calls[0]?.[0]).toBe( + "https://en.wikipedia.org/api/rest_v1/page/random/summary", + ); + expect(fetch.mock.calls[1]?.[1]).toEqual({ signal }); +}); + +it("passes the whole raw poem to Rust and aborts before fetching cancelled content", async () => { + const lines = [ + "_— 😀", + ...Array.from({ length: 110 }, (_, index) => `word${index}`), + ]; + const fetch = vi.fn( + async () => + new Response( + JSON.stringify([{ title: "Poem", author: "Writer", lines }]), + ), + ); + vi.stubGlobal("fetch", fetch); + const controller = new AbortController(); + const request = { id: 1, generation: 1, kind: "poetry", language: "english" }; + const language = { name: "english", words: ["a"] } as const; + const source = await fetchEngineSource( + request, + { ...language, words: [...language.words] }, + controller.signal, + ); + if (source.kind !== "poem") throw new Error("Wrong source"); + expect(source.lines.map(decodeText)).toEqual(lines); + controller.abort(); + await expect( + fetchEngineSource( + request, + { ...language, words: [...language.words] }, + controller.signal, + ), + ).rejects.toThrow(); + expect(fetch).toHaveBeenCalledOnce(); +}); diff --git a/frontend/__tests__/features/typing/content-host.spec.ts b/frontend/__tests__/features/typing/content-host.spec.ts new file mode 100644 index 0000000000..235d1e06b7 --- /dev/null +++ b/frontend/__tests__/features/typing/content-host.spec.ts @@ -0,0 +1,318 @@ +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import retry from "../../../../packages/typing-fixtures/oracle/wikipedia-retry.json" with { type: "json" }; +import { EngineFailure } from "@oxytype/typing-engine/failure"; +import { decodeText } from "@oxytype/typing-engine/text"; +const mocks = vi.hoisted(() => ({ + controller: new AbortController(), + prepare: vi.fn(), + generate: vi.fn(), + supply: vi.fn(), + failSource: vi.fn(), + updateQuoteQueue: vi.fn(), + quoteCatalog: vi.fn(), + request: { id: 1, generation: 1, kind: "wikipedia", language: "english" }, + sessionCommand: vi.fn<(command: { op: string; request?: unknown }) => void>(), + getLanguage: vi.fn(), + cachedFetchJson: vi.fn(), + createSession: vi.fn(), +})); +vi.mock("../../../src/features/typing/engine.client", () => ({ + getBrowserEngine: () => ({ + load: async () => undefined, + status: () => ({ state: "ready" }), + beginReplacement: () => ({ + prepare: mocks.prepare, + generate: mocks.generate, + supply: mocks.supply, + failSource: mocks.failSource, + request: () => mocks.request, + metadata: () => ({ currentQuote: null }), + }), + captureSession: () => { + const signal = mocks.controller.signal; + return { signal, isCurrent: () => !signal.aborted }; + }, + createPreparedSession: mocks.createSession, + content: () => ({ + updateQuoteQueue: mocks.updateQuoteQueue, + quoteCatalog: mocks.quoteCatalog, + }), + }), +})); +vi.mock("../../../src/features/typing/runtime/session.client", () => ({ + getEngineSession: () => ({ + attach: () => ({ words: [[97]], sectionIndexes: [0] }), + command: mocks.sessionCommand, + }), +})); +vi.mock("../../../src/ts/utils/json-data", () => ({ + getLanguage: mocks.getLanguage, + cachedFetchJson: mocks.cachedFetchJson, +})); +vi.mock("../../../src/ts/db", () => ({ getSnapshot: () => null })); +import { mountApplication } from "../../../src/app/application"; +import { getMutableConfig } from "../../../src/ts/config/store"; +import { getContentHost } from "../../../src/features/typing/runtime/content.client"; +import { + createTestApplication, + releaseTestApplication, +} from "../../__harness__/mock-application"; +releaseTestApplication(); +let dispose: () => void; +const language = { name: "english", words: ["a"], bcp47: "en" } as const; +beforeEach(() => { + vi.clearAllMocks(); + mocks.controller = new AbortController(); + mocks.prepare.mockReset(); + mocks.supply.mockReset(); + mocks.sessionCommand.mockReset(); + mocks.generate.mockReset().mockReturnValue({ + words: [[97, 32]], + sectionIndexes: [0], + hasTab: false, + hasNewline: false, + allRightToLeft: null, + allJoiningScript: false, + }); + mocks.getLanguage + .mockReset() + .mockResolvedValue({ ...language, words: [...language.words] }); + mocks.cachedFetchJson.mockReset().mockResolvedValue({ + language: "english", + groups: [ + [0, 10], + [11, 20], + [21, 30], + [31, 100], + ], + quotes: [{ id: 1, text: "a quote", length: 7, source: "Source" }], + }); + const app = createTestApplication(); + const release = mountApplication(app.application); + dispose = () => { + release(); + app.dispose(); + }; + Object.assign(getMutableConfig(), { + mode: "words", + words: 1, + language: "english", + funbox: ["wikipedia"], + }); +}); +afterEach(() => { + dispose(); + vi.unstubAllGlobals(); +}); + +it("retries the same pending native request after empty cleanup, matching original fetch ordering", async () => { + const fetch = vi.fn(); + for (let index = 0; index < 2; index++) { + fetch + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + title: `Article ${index + 1}`, + author: "Writer", + pageid: index + 1, + }), + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + query: { + pages: { [index + 1]: { extract: retry.extracts[index] } }, + }, + }), + ), + ); + } + vi.stubGlobal("fetch", fetch); + mocks.generate.mockImplementationOnce(() => { + throw new EngineFailure({ + code: "contentRequired", + message: "Content required", + recovery: [], + }); + }); + mocks.supply.mockImplementationOnce(() => { + throw new EngineFailure({ + code: "emptyContent", + message: "Section has no words", + recovery: [], + }); + }); + await getContentHost().prepare({ ...language, words: [...language.words] }); + expect(fetch.mock.calls.map(([url]) => url)).toEqual(retry.requests); + expect(mocks.supply).toHaveBeenCalledTimes(2); + for (const [index, call] of mocks.supply.mock.calls.entries()) { + expect(call[0]).toEqual(mocks.request); + expect(decodeText(call[1].plainText)).toBe(retry.extracts[index]); + } + expect(mocks.failSource).not.toHaveBeenCalled(); + expect(mocks.createSession).toHaveBeenCalledExactlyOnceWith(false); +}); + +it("does not supply or report failure after a pending source was cancelled", async () => { + const loaded = Promise.withResolvers<{ + name: string; + words: string[]; + bcp47: string; + }>(); + mocks.getLanguage.mockReturnValueOnce(loaded.promise); + mocks.generate.mockImplementationOnce(() => { + throw new EngineFailure({ + code: "contentRequired", + message: "Content required", + recovery: [], + }); + }); + const pending = getContentHost().prepare({ + ...language, + words: [...language.words], + }); + await vi.waitFor(() => expect(mocks.getLanguage).toHaveBeenCalledOnce()); + mocks.controller.abort(); + loaded.resolve({ ...language, words: [...language.words] }); + await expect(pending).rejects.toThrow(); + expect(mocks.supply).not.toHaveBeenCalled(); + expect(mocks.failSource).not.toHaveBeenCalled(); + expect(mocks.createSession).not.toHaveBeenCalled(); +}); + +it("refreshes quote lengths after failed selection and retains the raw cached-language asset", async () => { + Object.assign(getMutableConfig(), { mode: "quote", funbox: [] }); + mocks.prepare.mockImplementationOnce(() => { + throw new EngineFailure({ + code: "noQuoteLength", + message: "No quotes found for selected quote length", + recovery: [{ op: "setQuoteLengthAll" }], + }); + }); + const host = getContentHost(); + await expect( + host.prepare({ ...language, words: [...language.words] }), + ).rejects.toThrow("No quotes found"); + host.quoteLengthsChanged([0, 1, 2, 3]); + expect(mocks.updateQuoteQueue).toHaveBeenCalledWith( + [0, 1, 2, 3], + expect.objectContaining({ kind: "seeded" }), + ); + const generated = await host.prepare({ + ...language, + words: [...language.words], + }); + expect(generated.words).toEqual(["a"]); // Owned session snapshot has the final separator stripped. + expect(mocks.cachedFetchJson).toHaveBeenCalledOnce(); + expect(mocks.prepare.mock.calls[0]?.[0].quotes).toEqual( + mocks.prepare.mock.calls[1]?.[0].quotes, + ); +}); + +it("shares one source fetch and one supply command for repeated observations of the same token", async () => { + const loaded = Promise.withResolvers<{ + name: string; + words: string[]; + bcp47: string; + }>(); + mocks.getLanguage.mockReturnValueOnce(loaded.promise); + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify([ + { title: "Poem", author: "Writer", lines: ["raw _words"] }, + ]), + ), + ), + ); + const host = getContentHost(); + const request = { ...mocks.request, kind: "poetry" }; + const first = host.supplyActive(request); + expect(host.supplyActive(request)).toBe(first); + expect(mocks.getLanguage).toHaveBeenCalledOnce(); + loaded.resolve({ ...language, words: [...language.words] }); + await first; + expect(mocks.sessionCommand).toHaveBeenCalledOnce(); + expect(mocks.sessionCommand).toHaveBeenCalledWith( + expect.objectContaining({ + op: "supplyContent", + request, + source: expect.objectContaining({ kind: "poem" }), + }), + ); +}); + +it("shares raw quote assets with catalog readers and projects native group indices without another fetch", async () => { + Object.assign(getMutableConfig(), { mode: "quote", funbox: [] }); + mocks.quoteCatalog.mockReturnValue({ + language: "english", + length: 1, + groups: [[0], [], [], []], + quotes: [ + { + id: 1, + text: [0xd800], + source: "Source", + length: 1, + group: 0, + language: "english", + textSplit: [], + }, + ], + }); + const host = getContentHost(); + const catalog = await host.getQuotes("english"); + expect(catalog.quotes[0]?.text).toBe("\ud800"); + expect(catalog.groups[0]?.[0]).toBe(catalog.quotes[0]); + expect(mocks.quoteCatalog).toHaveBeenCalledWith( + expect.any(Object), + null, + expect.objectContaining({ kind: "seeded" }), + ); + await host.prepare({ ...language, words: [...language.words] }); + expect(mocks.cachedFetchJson).toHaveBeenCalledOnce(); + expect(mocks.prepare.mock.calls[0]?.[0].quotes).toEqual( + mocks.quoteCatalog.mock.calls[0]?.[0], + ); +}); + +it("rejects an invalid active source through native recovery without scoring it as typing failure", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify([ + { title: "Poem", author: "Writer", lines: ["raw text"] }, + ]), + ), + ), + ); + const request = { ...mocks.request, kind: "poetry" }; + mocks.sessionCommand.mockImplementation((command: { op: string }) => { + if (command.op === "supplyContent") { + throw new EngineFailure({ + code: "invalidContent", + message: "Content exceeds bound", + recovery: [], + }); + } + throw new EngineFailure({ + code: "sourceUnavailable", + message: "Source unavailable", + recovery: [], + }); + }); + await expect(getContentHost().supplyActive(request)).rejects.toMatchObject({ + code: "sourceUnavailable", + }); + expect( + mocks.sessionCommand.mock.calls.map(([command]) => command.op), + ).toEqual(["supplyContent", "failContent"]); + expect(mocks.sessionCommand.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ request }), + ); +}); diff --git a/frontend/__tests__/features/typing/engine-resource.spec.ts b/frontend/__tests__/features/typing/engine-resource.spec.ts new file mode 100644 index 0000000000..21ad4567db --- /dev/null +++ b/frontend/__tests__/features/typing/engine-resource.spec.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +const mocks = vi.hoisted(() => { + const session = { dispose: vi.fn() }; + const content = { + dispose: vi.fn(), + recycleSession: vi.fn(), + createSession: vi.fn(() => session), + }; + const engine = { createContent: vi.fn(() => content) }; + return { + session, + content, + engine, + load: vi.fn(async () => engine), + disposeLoader: vi.fn(), + createLoader: vi.fn(), + }; +}); +vi.mock("@oxytype/typing-engine/browser.client", () => ({ + createEngineLoader: () => { + mocks.createLoader(); + return { load: mocks.load, dispose: mocks.disposeLoader }; + }, +})); +import { mountApplication } from "../../../src/app/application"; +import { getBrowserEngine } from "../../../src/features/typing/engine.client"; +import { + createTestApplication, + releaseTestApplication, +} from "../../__harness__/mock-application"; +releaseTestApplication(); +let dispose: () => void; +beforeEach(() => { + vi.clearAllMocks(); + mocks.load.mockReset().mockResolvedValue(mocks.engine); + const app = createTestApplication(); + const release = mountApplication(app.application); + dispose = () => { + release(); + app.dispose(); + }; +}); +afterEach(() => dispose()); + +it("shares loading, retries failures and forbids sessions until ready", async () => { + const resource = getBrowserEngine(); + expect(resource.status().state).toBe("idle"); + expect(() => resource.content()).toThrow("not ready"); + mocks.load.mockRejectedValueOnce(new Error("interrupted download")); + const first = resource.load(); + expect(resource.load()).toBe(first); + await expect(first).rejects.toThrow("interrupted download"); + expect(resource.status().state).toBe("failed"); + await resource.load(); + expect(resource.status().state).toBe("ready"); + expect(mocks.createLoader).toHaveBeenCalledOnce(); + expect(mocks.load).toHaveBeenCalledTimes(2); +}); + +it("recycles a replaced session, invalidates host work and releases all app resources", async () => { + const resource = getBrowserEngine(); + await resource.load(); + const content = resource.beginReplacement(); + const generation = resource.generation(); + const lease = resource.captureSession(); + expect(lease.signal.aborted).toBe(false); + const session = resource.createPreparedSession(); + expect(resource.session()).toBe(session); + expect(() => resource.createPreparedSession()).toThrow("prior session"); + expect(resource.beginReplacement()).toBe(content); + expect(resource.generation()).toBeGreaterThan(generation); + expect(lease.signal.aborted).toBe(true); + expect(lease.isCurrent()).toBe(false); + expect(resource.captureSession().signal.aborted).toBe(false); + expect(mocks.content.recycleSession).toHaveBeenCalledExactlyOnceWith(session); + resource.createPreparedSession(true); + const active = resource.session(); + const pending = resource.captureSession(); + resource.invalidateSession(); + expect(pending.signal.aborted).toBe(true); + expect(resource.session()).toBe(active); + dispose(); + expect(mocks.session.dispose).toHaveBeenCalledOnce(); + expect(mocks.content.dispose).toHaveBeenCalledOnce(); + expect(mocks.disposeLoader).toHaveBeenCalledOnce(); + resource.dispose(); + expect(mocks.content.dispose).toHaveBeenCalledOnce(); + expect(resource.status().state).toBe("disposed"); + expect(() => resource.content()).toThrow("disposed"); + await expect(resource.load()).rejects.toThrow("disposed"); +}); + +it("cannot publish initialization after the app owner was disposed", async () => { + const completion = Promise.withResolvers(); + mocks.load.mockReturnValueOnce(completion.promise); + const resource = getBrowserEngine(); + const loading = resource.load(); + await vi.waitFor(() => expect(mocks.load).toHaveBeenCalledOnce()); + dispose(); + completion.resolve(mocks.engine); + await expect(loading).rejects.toThrow("disposed"); + expect(resource.status().state).toBe("disposed"); + expect(mocks.engine.createContent).not.toHaveBeenCalled(); +}); diff --git a/frontend/__tests__/features/typing/inspection.spec.ts b/frontend/__tests__/features/typing/inspection.spec.ts new file mode 100644 index 0000000000..d73e9a9be7 --- /dev/null +++ b/frontend/__tests__/features/typing/inspection.spec.ts @@ -0,0 +1,17 @@ +import "../../__harness__/native-engine"; +import { describe, expect, it } from "vite-plus/test"; +import { analyzeLog } from "@oxytype/typing-engine/browser.client"; +import { decodeText } from "@oxytype/typing-engine/text"; +import cases from "../../../../packages/typing-fixtures/oracle/inspection.json" with { type: "json" }; +import type { Log } from "@oxytype/typing-engine/browser.client"; + +describe("historical log inspection through optimized browser Wasm", () => { + it.for(cases)("$name", (reference) => { + const result = analyzeLog(reference.log as Log, { + op: "inspect", + }).inspection; + expect({ ...result, inputs: result.inputs.map(decodeText) }).toEqual( + reference.expected, + ); + }); +}); diff --git a/frontend/__tests__/features/typing/lifecycle.spec.ts b/frontend/__tests__/features/typing/lifecycle.spec.ts index cc3155c134..4c19ecedcb 100644 --- a/frontend/__tests__/features/typing/lifecycle.spec.ts +++ b/frontend/__tests__/features/typing/lifecycle.spec.ts @@ -8,12 +8,17 @@ const mocks = vi.hoisted(() => { paceReset: vi.fn(), pauseReplay: vi.fn(), sessionReset: vi.fn(), + engineRelease: vi.fn(), funboxClear: vi.fn(async () => undefined), clearGlobal: vi.fn(), layoutfluidHide: vi.fn(), altGrReset: vi.fn(), }; }); +vi.mock("../../../src/features/typing/engine.client", () => ({ + getBrowserEngine: () => ({ releaseSession: mocks.engineRelease }), +})); + vi.mock("../../../src/app/readiness", () => ({ preferencesReadiness: () => ({ wait: mocks.preferences }), })); @@ -179,6 +184,7 @@ it("tears down timers, funboxes and incomplete state synchronously on exit", () expect(mocks.paceReset).toHaveBeenCalledOnce(); expect(mocks.pauseReplay).toHaveBeenCalledOnce(); expect(mocks.sessionReset).toHaveBeenCalledOnce(); + expect(mocks.engineRelease).toHaveBeenCalledOnce(); expect(mocks.clearGlobal).toHaveBeenCalledOnce(); expect(mocks.funboxClear).toHaveBeenCalledOnce(); expect(mocks.layoutfluidHide).toHaveBeenCalledOnce(); diff --git a/frontend/__tests__/features/typing/session-projection.spec.ts b/frontend/__tests__/features/typing/session-projection.spec.ts new file mode 100644 index 0000000000..5d0e76b027 --- /dev/null +++ b/frontend/__tests__/features/typing/session-projection.spec.ts @@ -0,0 +1,239 @@ +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import type { Update } from "@oxytype/typing-engine/browser.client"; +const mocks = vi.hoisted(() => { + const empty: Update = { + state: { wordIndex: 0, inputValue: [], active: false, ended: false }, + events: [], + generation: 1, + automaticPending: false, + words: [], + logInvalidated: false, + awaitingContent: false, + slowTimer: false, + }; + return { + empty, + command: vi.fn(() => empty), + snapshot: vi.fn(() => ({ + state: empty.state, + words: [[97]], + sectionIndexes: [0], + allWordsGenerated: true, + hasTab: false, + hasNewline: false, + currentQuote: null, + })), + log: vi.fn(), + pace: vi.fn(() => ({ position: null, stepEnd: 0, lastWpm: 77 })), + liveStats: vi.fn(() => ({ + wpm: 120, + raw: 120, + acc: 100, + seconds: 1, + timerStartMs: 100, + timeLimitSeconds: null, + durationMs: 1000, + msSinceLastInput: null, + chars: { + allCorrect: 1, + correctWord: 1, + incorrect: 0, + extra: 0, + missed: 0, + }, + })), + generation: 1, + analyze: vi.fn(), + }; +}); +vi.mock("../../../src/features/typing/engine.client", () => ({ + getBrowserEngine: () => ({ + session: () => mocks, + generation: () => mocks.generation, + invalidateSession: () => { + mocks.generation++; + }, + api: () => ({ analyze: mocks.analyze }), + }), +})); +import { mountApplication } from "../../../src/app/application"; +import { getEngineSession } from "../../../src/features/typing/runtime/session.client"; +import { + createTestApplication, + releaseTestApplication, +} from "../../__harness__/mock-application"; +releaseTestApplication(); +let dispose: () => void; +beforeEach(() => { + vi.clearAllMocks(); + mocks.command.mockReturnValue(mocks.empty); + mocks.generation = 1; + const app = createTestApplication(); + const release = mountApplication(app.application); + dispose = () => { + release(); + app.dispose(); + }; +}); +afterEach(() => dispose()); + +it("projects lossless command deltas and observations without exporting a growing log", () => { + const session = getEngineSession(); + session.attach(); + const project = vi.fn(); + session.subscribe(project); + mocks.command.mockReturnValueOnce({ + ...mocks.empty, + state: { wordIndex: 0, inputValue: [0xd800], active: true, ended: false }, + events: [ + { + type: "input", + testMs: 0, + data: { + inputType: "insertText", + data: [0xd800], + inputValue: [0xd800], + wordIndex: 0, + charIndex: 0, + correct: false, + }, + }, + ], + }); + session.command({ + op: "insert", + data: [0xd800], + now: 123.25, + date: 456, + automatic: null, + isCompositionEnding: null, + correctShiftUsed: null, + }); + expect(session.getCurrentInput()).toBe("\ud800"); + expect(session.metadata()?.allWordsGenerated).toBe(true); + expect(session.getInputForWord(0)).toBe("\ud800"); + expect(project.mock.calls[0]?.[2][0].data).toMatchObject({ + data: "\ud800", + inputValue: "\ud800", + }); + session.observe( + { + type: "composition", + testMs: 0, + data: { event: "update", data: "\udc00", wordIndex: 0 }, + }, + 124.5, + ); + expect(mocks.command).toHaveBeenLastCalledWith({ + op: "observe", + now: 124.5, + eventType: "composition", + eventData: { event: "update", data: [0xdc00], wordIndex: 0 }, + }); + expect(mocks.log).not.toHaveBeenCalled(); +}); + +it("notifies a deferred finish exactly once and acknowledges at the host completion time", () => { + const session = getEngineSession(); + session.attach(); + const finish = vi.fn(); + session.on("finish", finish); + mocks.command.mockReturnValue({ + ...mocks.empty, + state: { ...mocks.empty.state, active: true }, + finish: { reason: "difficulty" }, + }); + session.command({ + op: "insert", + now: 500, + date: 1, + data: [97], + automatic: null, + isCompositionEnding: null, + correctShiftUsed: null, + }); + session.command({ op: "advance", now: 501, date: 1 }); + expect(finish).toHaveBeenCalledExactlyOnceWith({ reason: "difficulty" }); + expect(session.getFinishReason()).toBe("difficulty"); + mocks.command.mockReturnValue(mocks.empty); + session.end(550.75); + expect(mocks.command).toHaveBeenLastCalledWith( + expect.objectContaining({ op: "acknowledgeFinish", now: 550.75 }), + ); + session.reset(); + expect(session.getFinishReason()).toBeUndefined(); + expect(session.getInputForWord(0)).toBe(""); +}); + +it("does not dispatch stale completion after a projection replaces the session", () => { + const session = getEngineSession(); + session.attach(); + const finish = vi.fn(); + session.on("finish", finish); + session.subscribe(() => { + mocks.generation++; + }); + mocks.command.mockReturnValue({ ...mocks.empty, finish: {} }); + session.finish(500); + expect(finish).not.toHaveBeenCalled(); +}); + +it("projects cleared-next-word deletion without retaining the Firefox residue", () => { + const session = getEngineSession(); + session.attach(); + mocks.command.mockReturnValueOnce({ + ...mocks.empty, + events: [ + { + type: "input", + testMs: 1, + data: { + inputType: "insertText", + data: [120], + inputValue: [120], + wordIndex: 1, + charIndex: 0, + correct: false, + }, + }, + { + type: "input", + testMs: 2, + data: { + inputType: "deleteWordBackward", + inputValue: [], + wordIndex: 0, + charIndex: 0, + clearedNextWord: true, + }, + }, + ], + }); + session.command({ + op: "delete", + now: 2, + date: 0, + kind: "deleteWordBackward", + observedInput: [], + sentinelRemoved: true, + }); + expect(session.getInputForWord(1)).toBe(""); +}); + +it("retains the captured tick/explicit finish clock when acknowledging a deferred timer result", () => { + const session = getEngineSession(); + session.attach(); + mocks.command.mockReturnValue({ + ...mocks.empty, + state: { ...mocks.empty.state, active: true }, + finish: { reason: "min accuracy" }, + }); + session.command({ op: "advance", now: 1000.25, date: 1234 }); + mocks.command.mockReturnValue(mocks.empty); + session.end(1050); + expect(mocks.command).toHaveBeenLastCalledWith({ + op: "acknowledgeFinish", + now: 1000.25, + date: 1234, + }); +}); diff --git a/frontend/__tests__/input/handlers/before-insert-text.spec.ts b/frontend/__tests__/input/handlers/before-insert-text.spec.ts index b8581f4a94..7a3432b200 100644 --- a/frontend/__tests__/input/handlers/before-insert-text.spec.ts +++ b/frontend/__tests__/input/handlers/before-insert-text.spec.ts @@ -1,4 +1,11 @@ -import { describe, it, expect, beforeEach, vi } from "vite-plus/test"; +import { + describe, + it, + expect, + beforeEach, + afterEach, + vi, +} from "vite-plus/test"; const mockState = vi.hoisted(() => ({ inputValue: "", @@ -24,10 +31,23 @@ vi.mock("../../../src/features/typing/runtime/test-words", () => ({ }, })); -vi.mock("../../../src/features/typing/runtime/events/data", () => ({ - getCurrentInput: () => mockState.currentInput, +const native = vi.hoisted(() => ({ + session: undefined as + | import("@oxytype/typing-engine/browser.client").BrowserSession + | undefined, +})); +vi.mock("../../../src/features/typing/runtime/session.client", () => ({ + getEngineSession: () => ({ + isPrepared: () => native.session !== undefined, + command: ( + command: import("@oxytype/typing-engine/browser.client").Command, + ) => native.session?.command(command), + preflight: ( + data: string, + layout: import("@oxytype/typing-engine/browser.client").InsertionLayout, + ) => native.session?.preflight(encodeText(data), layout), + }), })); - vi.mock("../../../src/features/typing/runtime/test-ui", () => ({ activeWordTop: 0, activeWordHeight: 10, @@ -56,6 +76,13 @@ vi.mock("../../../src/features/typing/runtime/funbox/list", () => ({ })); import { onBeforeInsertText } from "../../../src/ts/input/handlers/before-insert-text"; +import { nativeSession } from "../../__harness__/native-engine"; +import { encodeText } from "@oxytype/typing-engine/text"; +import { getMutableConfig } from "../../../src/ts/config/store"; +afterEach(() => { + native.session?.dispose(); + native.session = undefined; +}); import { __testing } from "../../../src/ts/config/testing"; const { replaceConfig } = __testing; @@ -75,6 +102,16 @@ describe("onBeforeInsertText - line overflow", () => { difficulty: "normal", strictSpace: false, }); + native.session = nativeSession(getMutableConfig(), ["hello ", "world"]); + native.session.command({ + op: "insert", + data: encodeText("hello"), + now: 0, + date: 0, + correctShiftUsed: null, + automatic: null, + isCompositionEnding: null, + }); }); it("blocks an extra letter that would push the word onto the next line", () => { diff --git a/frontend/__tests__/input/handlers/insert-text.spec.ts b/frontend/__tests__/input/handlers/insert-text.spec.ts index 725b80e248..79242db8d3 100644 --- a/frontend/__tests__/input/handlers/insert-text.spec.ts +++ b/frontend/__tests__/input/handlers/insert-text.spec.ts @@ -1,4 +1,11 @@ -import { describe, it, expect, beforeEach, vi } from "vite-plus/test"; +import { + describe, + it, + expect, + beforeEach, + afterEach, + vi, +} from "vite-plus/test"; // The input element and the event log are the two things delete-on-error // writes to, and they must agree. The element is faked (mirroring the real @@ -17,9 +24,6 @@ vi.mock("../../../src/ts/input/input-element", () => ({ appendToInputElementValue: (value: string) => { inputEl.value += value; }, - replaceInputElementLastValueChar: (char: string) => { - inputEl.value = ` ${inputEl.value.slice(1).slice(0, -1)}${char}`; - }, getInputElement: () => null, moveInputElementCaretToTheEnd: () => undefined, isInputElementFocused: () => true, @@ -34,12 +38,22 @@ const mockState = vi.hoisted(() => ({ wordsScrolledOff: new Set(), })); -const nav = vi.hoisted(() => ({ - goToNextWord: vi.fn(), - goToPreviousWord: vi.fn(), +const native = vi.hoisted(() => ({ + session: undefined as + | import("@oxytype/typing-engine/browser.client").BrowserSession + | undefined, +})); +vi.mock("../../../src/features/typing/engine.client", () => ({ + getBrowserEngine: () => ({ + session: () => native.session, + generation: () => 1, + invalidateSession: () => undefined, + captureSession: () => ({ isCurrent: () => true }), + }), +})); +vi.mock("../../../src/features/typing/runtime/content.client", () => ({ + getContentHost: () => ({ supplyActive: vi.fn() }), })); -vi.mock("../../../src/ts/input/helpers/word-navigation", () => nav); - vi.mock("../../../src/features/typing/runtime/test-words", () => { type CommitChar = " " | "\n" | ""; type Word = { text: string; textWithCommit: string; commit: CommitChar }; @@ -108,9 +122,6 @@ vi.mock("../../../src/features/typing/runtime/test-logic", () => ({ finish: vi.fn(), addWord: vi.fn(), })); -vi.mock("../../../src/features/typing/runtime/weak-spot", () => ({ - updateScore: vi.fn(), -})); vi.mock("../../../src/ts/events/keymap", () => ({ flash: vi.fn() })); vi.mock("../../../src/ts/states/notifications", () => ({ showNoticeNotification: vi.fn(), @@ -119,32 +130,25 @@ vi.mock("../../../src/ts/states/composition", () => ({ getComposing: () => false, getData: () => "", })); -vi.mock("../../../src/features/typing/runtime/words-generator", () => ({ - areAllWordsGenerated: () => true, -})); -vi.mock("../../../src/ts/input/handlers/before-insert-text", () => ({ - onBeforeInsertText: () => false, -})); -vi.mock("../../../src/ts/input/helpers/fail-or-finish", () => ({ - checkIfFailedDueToDifficulty: () => false, - checkIfFailedDueToMinBurst: () => false, - checkIfFinished: () => false, -})); - import { onInsertText } from "../../../src/ts/input/handlers/insert-text"; import { resetTestEvents, getAllTestEvents, - getInputForWord, } from "../../../src/features/typing/runtime/events/data"; -import { - findInputValueMismatches, - getEventsForWord, -} from "@oxytype/typing-core/events/helpers"; -import type { InputEventNoMs } from "@oxytype/typing-core/events/types"; + +import type { InputEventNoMs } from "@oxytype/web-shared/events/types"; import { words as TestWords } from "../../../src/features/typing/runtime/test-words"; import { __testing } from "../../../src/ts/config/testing"; -import { DeleteInputType } from "../../../src/ts/input/helpers/input-type"; +import { nativeSession } from "../../__harness__/native-engine"; +import { getEngineSession } from "../../../src/features/typing/runtime/session.client"; +import { getMutableConfig } from "../../../src/ts/config/store"; +import { decodeText } from "@oxytype/typing-engine/text"; +let unsubscribe: (() => void) | undefined; +afterEach(() => { + unsubscribe?.(); + native.session?.dispose(); + native.session = undefined; +}); const { replaceConfig } = __testing; @@ -155,35 +159,22 @@ function getInput(): string { return inputEl.value.slice(1); } -// mirrors goToNextWord's observable effects: clear the input, advance the word -nav.goToNextWord.mockImplementation(async () => { - setInput(""); - mockState.activeWordIndex++; - return { increasedWordIndex: true, lastBurst: null }; -}); - -// mirrors goToPreviousWord (minus the nospace branch): step back a word and -// restore that word's input, dropping its separator for a single backspace -nav.goToPreviousWord.mockImplementation((inputType: DeleteInputType) => { - if (mockState.activeWordIndex === 0) { - setInput(""); - return; - } - mockState.activeWordIndex--; - if (inputType === "deleteWordBackward") { - setInput(""); - return; - } - const word = getInputForWord(mockState.activeWordIndex); - setInput( - word.endsWith("\n") || word.endsWith(" ") ? word.slice(0, -1) : word, - ); -}); - function pushWords(...words: string[]): void { words.forEach((word, i) => { TestWords.push(i === words.length - 1 ? word : `${word} `, i); }); + native.session = nativeSession( + getMutableConfig(), + words.map((word, index) => + index + 1 === words.length ? word : `${word} `, + ), + ); + const session = getEngineSession(); + session.attach(); + unsubscribe = session.subscribe((update) => { + mockState.activeWordIndex = update.state.wordIndex; + setInput(decodeText(update.inputDisplayValue ?? update.state.inputValue)); + }); } // mirrors emulateInsertText: the character is in the element before the @@ -194,8 +185,15 @@ async function type(data: string, now = 1000): Promise { } function inputEventsForWord(wordIndex: number): InputEventNoMs[] { - return getEventsForWord(getAllTestEvents(), wordIndex).filter( - (e): e is InputEventNoMs => e.type === "input", + return getAllTestEvents().filter( + (event): event is InputEventNoMs => + event.type === "input" && event.data.wordIndex === wordIndex, + ); +} + +function expectRecordedInput(index: number): void { + expect(inputEventsForWord(index).at(-1)?.data.inputValue ?? "").toBe( + getEngineSession().getInputForWord(index), ); } @@ -242,7 +240,7 @@ describe("onInsertText - delete on error", () => { ["deleteContentBackward", 2, "h"], ]); expect(getInput()).toBe("h"); - expect(findInputValueMismatches(inputEventsForWord(0))).toEqual([]); + expectRecordedInput(0); }); it("deletes only the incorrect char at the start of a word", async () => { @@ -251,7 +249,7 @@ describe("onInsertText - delete on error", () => { expect(deletesForWord(0)).toEqual([["deleteContentBackward", 1, ""]]); expect(getInput()).toBe(""); - expect(findInputValueMismatches(inputEventsForWord(0))).toEqual([]); + expectRecordedInput(0); }); it("does not go back a word without a hard variant", async () => { @@ -266,7 +264,6 @@ describe("onInsertText - delete on error", () => { await type("x"); - expect(nav.goToPreviousWord).not.toHaveBeenCalled(); expect(mockState.activeWordIndex).toBe(1); }); @@ -276,13 +273,13 @@ describe("onInsertText - delete on error", () => { await type("e"); await type(" "); - expect(nav.goToNextWord).not.toHaveBeenCalled(); + expect(getEngineSession().state()?.wordIndex).toBe(0); expect(mockState.activeWordIndex).toBe(0); expect(deletesForWord(0)).toEqual([ ["deleteContentBackward", 3, "he"], ["deleteContentBackward", 2, "h"], ]); - expect(findInputValueMismatches(inputEventsForWord(0))).toEqual([]); + expectRecordedInput(0); }); }); @@ -299,7 +296,7 @@ describe("onInsertText - delete on error", () => { expect(deletesForWord(0)).toEqual([["deleteWordBackward", 3, ""]]); expect(getInput()).toBe(""); - expect(findInputValueMismatches(inputEventsForWord(0))).toEqual([]); + expectRecordedInput(0); }); }); @@ -312,9 +309,7 @@ describe("onInsertText - delete on error", () => { await type("x"); - expect(nav.goToPreviousWord).toHaveBeenCalledWith( - "deleteContentBackward", - ); + expect(getEngineSession().state()?.wordIndex).toBe(0); expect(mockState.activeWordIndex).toBe(0); // the incorrect char is deleted from the word it was typed in... expect(deletesForWord(1)).toEqual([["deleteContentBackward", 1, ""]]); @@ -323,8 +318,8 @@ describe("onInsertText - delete on error", () => { ["deleteContentBackward", 5, "hello"], ]); expect(getInput()).toBe("hello"); - expect(findInputValueMismatches(inputEventsForWord(0))).toEqual([]); - expect(findInputValueMismatches(inputEventsForWord(1))).toEqual([]); + expectRecordedInput(0); + expectRecordedInput(1); }); it("word_hard clears the word it regresses into", async () => { @@ -334,7 +329,7 @@ describe("onInsertText - delete on error", () => { await type("x"); - expect(nav.goToPreviousWord).toHaveBeenCalledWith("deleteWordBackward"); + expect(getEngineSession().state()?.wordIndex).toBe(0); expect(deletesForWord(1)).toEqual([["deleteWordBackward", 1, ""]]); // the whole previous word goes too, so the post-navigation length is 0 expect(deletesForWord(0)).toEqual([["deleteWordBackward", 0, ""]]); @@ -347,7 +342,6 @@ describe("onInsertText - delete on error", () => { await type("x"); - expect(nav.goToPreviousWord).not.toHaveBeenCalled(); expect(mockState.activeWordIndex).toBe(0); expect(deletesForWord(0)).toEqual([["deleteContentBackward", 1, ""]]); }); @@ -360,7 +354,6 @@ describe("onInsertText - delete on error", () => { await type("x"); - expect(nav.goToPreviousWord).not.toHaveBeenCalled(); expect(mockState.activeWordIndex).toBe(1); expect(deletesForWord(1)).toEqual([["deleteContentBackward", 1, ""]]); expect(getInput()).toBe(""); @@ -374,7 +367,6 @@ describe("onInsertText - delete on error", () => { await type("x"); - expect(nav.goToPreviousWord).not.toHaveBeenCalled(); expect(mockState.activeWordIndex).toBe(1); expect(getInput()).toBe(""); }); diff --git a/frontend/__tests__/input/helpers/fail-or-finish.spec.ts b/frontend/__tests__/input/helpers/fail-or-finish.spec.ts deleted file mode 100644 index 319fb0daa7..0000000000 --- a/frontend/__tests__/input/helpers/fail-or-finish.spec.ts +++ /dev/null @@ -1,460 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterAll } from "vite-plus/test"; -import { - checkIfFailedDueToMinBurst, - checkIfFailedDueToDifficulty, - checkIfFinished, -} from "../../../src/ts/input/helpers/fail-or-finish"; -import { __testing } from "../../../src/ts/config/testing"; -import * as Misc from "@oxytype/typing-core/mode"; -import * as Strings from "../../../src/ts/utils/strings"; - -const { replaceConfig } = __testing; - -vi.mock("@oxytype/typing-core/mode", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - whorf: vi.fn(), - }; -}); - -vi.mock("../../../src/ts/utils/strings", async (importOriginal) => ({ - ...(await importOriginal()), - isSpace: vi.fn(), -})); - -describe("checkIfFailedDueToMinBurst", () => { - beforeEach(() => { - vi.clearAllMocks(); - replaceConfig({ - minBurst: "off", - mode: "time", - minBurstCustomSpeed: 100, - }); - vi.mocked(Misc.whorf).mockReturnValue(0); - }); - - afterAll(() => { - replaceConfig({}); - }); - - it.each([ - { - desc: "returns false if minBurst is off", - config: { minBurst: "off" }, - lastBurst: 50, - expected: false, - }, - { - desc: "returns false if lastBurst is null", - config: { minBurst: "fixed" }, - lastBurst: null, - expected: false, - }, - { - desc: "returns true if fixed burst is too slow", - config: { minBurst: "fixed", minBurstCustomSpeed: 100 }, - lastBurst: 99, - expected: true, - }, - { - desc: "returns false if fixed burst is fast enough", - config: { minBurst: "fixed", minBurstCustomSpeed: 100 }, - lastBurst: 100, - expected: false, - }, - { - desc: "returns true if flex burst is too slow", - config: { minBurst: "flex", minBurstCustomSpeed: 100 }, - lastBurst: 49, - whorfRet: 50, - expected: true, - }, - { - desc: "returns false if flex burst is fast enough", - config: { minBurst: "flex", minBurstCustomSpeed: 100 }, - lastBurst: 50, - whorfRet: 50, - expected: false, - }, - ])("$desc", ({ config, lastBurst, whorfRet, expected }) => { - replaceConfig(config as any); - if (whorfRet !== undefined) { - vi.mocked(Misc.whorf).mockReturnValue(whorfRet); - } - - const result = checkIfFailedDueToMinBurst({ - testInputWithData: "test", - currentWord: "test", - lastBurst, - }); - - expect(result).toBe(expected); - }); - - it("uses correct length for whorf calculation in zen mode", () => { - replaceConfig({ minBurst: "flex", mode: "zen", minBurstCustomSpeed: 100 }); - checkIfFailedDueToMinBurst({ - testInputWithData: "zeninput", - currentWord: "ignored", - lastBurst: 50, - }); - expect(Misc.whorf).toHaveBeenCalledWith(100, 8); - }); - - it("uses correct length for whorf calculation in normal mode", () => { - replaceConfig({ minBurst: "flex", mode: "time", minBurstCustomSpeed: 100 }); - checkIfFailedDueToMinBurst({ - testInputWithData: "input", - currentWord: "target", - lastBurst: 50, - }); - expect(Misc.whorf).toHaveBeenCalledWith(100, 6); - }); -}); - -describe("checkIfFailedDueToDifficulty", () => { - beforeEach(() => { - replaceConfig({ - mode: "time", - difficulty: "normal", - }); - }); - - afterAll(() => { - replaceConfig({}); - }); - - it.each([ - { - desc: "zen mode, master - never fails", - config: { mode: "zen", difficulty: "master" }, - correct: false, - data: " ", - testInput: "hello", - targetWord: "hello ", - commitCharacterType: "separator", - expected: false, - }, - { - desc: "zen mode - never fails", - config: { mode: "zen", difficulty: "normal" }, - correct: false, - data: " ", - testInput: "hello", - targetWord: "hello ", - commitCharacterType: "separator", - expected: false, - }, - // - { - desc: "normal typing incorrect- never fails", - config: { difficulty: "normal" }, - correct: false, - data: "h", - testInput: "hell", - targetWord: "hello", - commitCharacterType: false, - expected: false, - }, - { - desc: "normal typing space incorrect - never fails", - config: { difficulty: "normal" }, - correct: false, - data: " ", - testInput: "hell", - targetWord: "hello ", - commitCharacterType: "separator", - expected: false, - }, - { - desc: "normal typing correct - never fails", - config: { difficulty: "normal" }, - correct: true, - data: "o", - testInput: "hell", - targetWord: "hello", - commitCharacterType: false, - expected: false, - }, - { - desc: "normal typing space correct - never fails", - config: { difficulty: "normal" }, - correct: true, - data: " ", - testInput: "hello", - targetWord: "hello ", - commitCharacterType: "separator", - expected: false, - }, - // - { - desc: "expert - fail if incorrect space", - config: { difficulty: "expert" }, - correct: false, - data: " ", - testInput: "he", - targetWord: "hello ", - commitCharacterType: "separator", - expected: true, - }, - { - desc: "expert - dont fail if space is the first character", - config: { difficulty: "expert" }, - correct: false, - data: " ", - testInput: "", - targetWord: "hello ", - commitCharacterType: "separator", - expected: false, - }, - { - desc: "expert: - dont fail if just typing", - config: { difficulty: "expert" }, - correct: false, - data: "h", - testInput: "hell", - targetWord: "hello", - commitCharacterType: false, - expected: false, - }, - { - desc: "expert: - dont fail if just typing", - config: { difficulty: "expert" }, - correct: true, - data: "o", - testInput: "hell", - targetWord: "hello", - commitCharacterType: false, - expected: false, - }, - { - // nospace commits on the final letter (empty input for a 1-letter word); - // an incorrect commit must still fail expert - desc: "expert - fail on incorrect nospace 1-letter word on empty input", - config: { difficulty: "expert" }, - correct: false, - data: "b", - testInput: "", - targetWord: "a", - commitCharacterType: "nospace", - expected: true, - }, - { - desc: "expert - dont fail on correct nospace 1-letter word on empty input", - config: { difficulty: "expert" }, - correct: true, - data: "a", - testInput: "", - targetWord: "a", - commitCharacterType: "nospace", - expected: false, - }, - { - desc: "expert - fail on incorrect nospace multi-letter word commit", - config: { difficulty: "expert" }, - correct: false, - data: "o", - testInput: "helx", - targetWord: "hello", - commitCharacterType: "nospace", - expected: true, - }, - // - { - desc: "master - fail if incorrect char", - config: { difficulty: "master" }, - correct: false, - data: "h", - testInput: "hell", - targetWord: "hello", - commitCharacterType: false, - expected: true, - }, - { - desc: "master - fail if incorrect first space", - config: { difficulty: "master" }, - correct: true, - data: " ", - testInput: "", - targetWord: "hello ", - commitCharacterType: "separator", - expected: false, - }, - { - desc: "master - dont fail if correct char", - config: { difficulty: "master" }, - correct: true, - data: "a", - testInput: "te", - targetWord: "tea", - commitCharacterType: false, - expected: false, - }, - { - desc: "master - dont fail if correct space", - config: { difficulty: "master" }, - correct: true, - data: " ", - testInput: "hello", - targetWord: "hello ", - commitCharacterType: "separator", - expected: false, - }, - ])( - "$desc", - ({ - config, - correct, - data, - testInput, - targetWord, - commitCharacterType, - expected, - }) => { - replaceConfig(config as any); - const result = checkIfFailedDueToDifficulty({ - data, - testInput, - targetWord, - correct, - commitCharacterType: commitCharacterType as - | "separator" - | "nospace" - | false, - }); - expect(result).toBe(expected); - }, - ); -}); - -describe("checkIfFinished", () => { - beforeEach(() => { - vi.clearAllMocks(); - replaceConfig({ - quickEnd: false, - stopOnError: "off", - deleteOnError: "off", - }); - vi.mocked(Strings.isSpace).mockReturnValue(false); - }); - - afterAll(() => { - replaceConfig({}); - }); - - it.each([ - { - desc: "false if not all words typed", - allWordsTyped: false, - testInputWithData: "word", - currentWord: "word", - expected: false, - }, - { - desc: "false if not all words generated, but on the last word", - allWordsGenerated: false, - allWordsTyped: true, - testInputWithData: "word", - currentWord: "word", - expected: false, - }, - { - desc: "true if last word is correct", - allWordsTyped: true, - testInputWithData: "word", - currentWord: "word", - expected: true, - }, - { - desc: "true if quickEnd enabled and lengths match", - allWordsTyped: true, - testInputWithData: "asdf", - currentWord: "word", - config: { quickEnd: true }, - expected: true, - }, - { - desc: "false if quickEnd disabled and lengths match", - allWordsTyped: true, - testInputWithData: "asdf", - currentWord: "word", - config: { quickEnd: false }, - expected: false, - }, - { - //the character is taken back, so it must not end the test - desc: "false if quickEnd enabled, lengths match, but stop on error is on", - allWordsTyped: true, - testInputWithData: "asdf", - currentWord: "word", - config: { quickEnd: true, stopOnError: "letter" }, - expected: false, - }, - { - desc: "false if quickEnd enabled, lengths match, but delete on error is on", - allWordsTyped: true, - testInputWithData: "asdf", - currentWord: "word", - config: { quickEnd: true, deleteOnError: "letter" }, - expected: false, - }, - { - desc: "true if quickEnd enabled, lengths match and delete on error is off", - allWordsTyped: true, - testInputWithData: "asdf", - currentWord: "word", - config: { quickEnd: true, deleteOnError: "off" }, - expected: true, - }, - { - desc: "true if space on the last word", - allWordsTyped: true, - testInputWithData: "wo ", - currentWord: "word", - goingToNextWord: true, - expected: true, - }, - { - desc: "false if still typing, quickend disabled", - allWordsTyped: true, - testInputWithData: "wordwordword", - currentWord: "word", - expected: false, - }, - ] as { - desc: string; - allWordsTyped: boolean; - allWordsGenerated?: boolean; - goingToNextWord: boolean; - testInputWithData: string; - currentWord: string; - config?: Record; - isSpace?: boolean; - expected: boolean; - }[])( - "$desc", - ({ - allWordsTyped, - allWordsGenerated, - goingToNextWord, - testInputWithData, - currentWord, - config, - expected, - }) => { - if (config) replaceConfig(config as any); - - const result = checkIfFinished({ - goingToNextWord, - testInputWithData, - currentWord, - allWordsTyped, - allWordsGenerated: allWordsGenerated ?? true, - }); - - expect(result).toBe(expected); - }, - ); -}); diff --git a/frontend/__tests__/input/helpers/util.spec.ts b/frontend/__tests__/input/helpers/util.spec.ts deleted file mode 100644 index a0358e5f10..0000000000 --- a/frontend/__tests__/input/helpers/util.spec.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vite-plus/test"; -import { getCommitCharacterType } from "../../../src/ts/input/helpers/util"; -import * as FunboxList from "../../../src/features/typing/runtime/funbox/list"; - -vi.mock("../../../src/features/typing/runtime/funbox/list", () => ({ - isFunboxActiveWithProperty: vi.fn(), -})); - -const isFunboxActiveWithProperty = vi.mocked( - FunboxList.isFunboxActiveWithProperty, -); - -describe("getCommitCharacterType", () => { - beforeEach(() => { - vi.clearAllMocks(); - isFunboxActiveWithProperty.mockReturnValue(false); - }); - - it("returns 'separator' for a regular space", () => { - expect( - getCommitCharacterType({ - data: " ", - inputValue: "tes", - targetWord: "test", - }), - ).toBe("separator"); - }); - - it.each([ - [" ", "ideographic"], - [" ", "non-breaking"], - [" ", "em"], - ["​", "zero width"], - ])("returns 'separator' for %s (%s space)", (data) => { - expect( - getCommitCharacterType({ data, inputValue: "tes", targetWord: "test" }), - ).toBe("separator"); - }); - - it("returns 'separator' for a newline", () => { - expect( - getCommitCharacterType({ - data: "\n", - inputValue: "tes", - targetWord: "test", - }), - ).toBe("separator"); - }); - - it("returns false for a regular letter when nospace is inactive", () => { - expect( - getCommitCharacterType({ - data: "t", - inputValue: "tes", - targetWord: "test", - }), - ).toBe(false); - expect(isFunboxActiveWithProperty).toHaveBeenCalledWith("nospace"); - }); - - describe("nospace funbox", () => { - beforeEach(() => { - isFunboxActiveWithProperty.mockReturnValue(true); - }); - - it("returns 'nospace' when the char completes the target word", () => { - expect( - getCommitCharacterType({ - data: "t", - inputValue: "tes", - targetWord: "test", - }), - ).toBe("nospace"); - }); - - it("returns false when the word is not yet complete", () => { - expect( - getCommitCharacterType({ - data: "s", - inputValue: "te", - targetWord: "test", - }), - ).toBe(false); - }); - - it("returns false when input already exceeds the target length", () => { - expect( - getCommitCharacterType({ - data: "x", - inputValue: "test", - targetWord: "test", - }), - ).toBe(false); - }); - - it("still returns 'separator' for a space", () => { - expect( - getCommitCharacterType({ - data: " ", - inputValue: "tes", - targetWord: "test", - }), - ).toBe("separator"); - }); - }); -}); diff --git a/frontend/__tests__/input/helpers/validation.spec.ts b/frontend/__tests__/input/helpers/validation.spec.ts deleted file mode 100644 index 10932b0790..0000000000 --- a/frontend/__tests__/input/helpers/validation.spec.ts +++ /dev/null @@ -1,371 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterAll } from "vite-plus/test"; -import { - isCharCorrect, - shouldGoToNextWord, -} from "../../../src/ts/input/helpers/validation"; -import { __testing } from "../../../src/ts/config/testing"; -import * as FunboxList from "../../../src/features/typing/runtime/funbox/list"; -import * as Strings from "../../../src/ts/utils/strings"; - -const { replaceConfig } = __testing; - -// Mock dependencies -vi.mock("../../../src/features/typing/runtime/funbox/list", () => ({ - findSingleActiveFunboxWithFunction: vi.fn(), -})); - -vi.mock("../../../src/ts/utils/strings", async () => { - const actual = await vi.importActual( - "../../../src/ts/utils/strings", - ); - return { - ...actual, - areCharactersVisuallyEqual: vi.fn(), - }; -}); - -describe("isCharCorrect", () => { - beforeEach(() => { - vi.clearAllMocks(); - // Reset Config defaults - replaceConfig({ - mode: "words", - language: "english", - stopOnError: "off", - difficulty: "normal", - strictSpace: false, - }); - vi.mocked(FunboxList.findSingleActiveFunboxWithFunction).mockReturnValue( - undefined, - ); - vi.mocked(Strings.areCharactersVisuallyEqual).mockReturnValue(false); - }); - - afterAll(() => { - replaceConfig({}); - }); - - describe("Zen Mode", () => { - it("always returns true", () => { - replaceConfig({ mode: "zen" }); - expect( - isCharCorrect({ - data: "a", - inputValue: "test", - targetWord: "word", - correctShiftUsed: true, - }), - ).toBe(true); - }); - }); - - describe("Shift Key", () => { - it("returns false if correct shift was not used", () => { - expect( - isCharCorrect({ - data: "A", - inputValue: "test", - targetWord: "testA", - correctShiftUsed: false, - }), - ).toBe(false); - }); - }); - - describe("Space Handling", () => { - it.each([ - ["returns false in the middle of a word", " ", "wor", "word", false], - ["returns false at the start of a word", " ", "", "word", false], - [ - "returns false when longer than a word", - " ", - "wordwordword", - "word", - false, - ], - ])("%s", (_desc, char, input, word, expected) => { - expect( - isCharCorrect({ - data: char, - inputValue: input, - targetWord: word, - correctShiftUsed: true, - }), - ).toBe(expected); - }); - }); - - describe("Separator at the end of a word", () => { - // target words store their separator as a trailing space/newline; typing - // that separator at the separator position is a correct char regardless of - // whether the preceding letters were correct (word-level correctness is - // derived from the per-letter events elsewhere) - it.each([ - ["space separator at the correct position", " ", "word", "word ", true], - [ - "space separator is correct even after a wrong letter", - " ", - "worx", - "word ", - true, - ], - [ - "newline separator at the correct position", - "\n", - "word", - "word\n", - true, - ], - [ - "newline separator is correct even after a wrong letter", - "\n", - "xord", - "word\n", - true, - ], - ])("%s", (_desc, char, input, word, expected) => { - expect( - isCharCorrect({ - data: char, - inputValue: input, - targetWord: word, - correctShiftUsed: true, - }), - ).toBe(expected); - }); - }); - - describe("Standard Matching", () => { - it.each([ - ["a", "te", "tea", true], - ["b", "te", "tea", false], - ["x", "tea", "tea", false], - ])( - "char '%s' for input '%s' (current word '%s') -> %s", - (char, input, word, expected) => { - expect( - isCharCorrect({ - data: char, - inputValue: input, - targetWord: word, - correctShiftUsed: true, - }), - ).toBe(expected); - }, - ); - }); -}); - -describe("shouldGoToNextWord", () => { - // target words store their separator as a trailing space - beforeEach(() => { - replaceConfig({ - mode: "time", - stopOnError: "off", - deleteOnError: "off", - strictSpace: false, - difficulty: "normal", - }); - }); - - afterAll(() => { - replaceConfig({}); - }); - - it("returns false when the input is not a commit character", () => { - expect( - shouldGoToNextWord({ - data: "a", - inputValue: "test", - targetWord: "test ", - commitCharacterType: false, - }), - ).toBe(false); - }); - - it("returns true in zen mode", () => { - replaceConfig({ mode: "zen" }); - expect( - shouldGoToNextWord({ - data: " ", - inputValue: "test", - targetWord: "test ", - commitCharacterType: "separator", - }), - ).toBe(true); - }); - - it("returns true when committing a word with a newline", () => { - expect( - shouldGoToNextWord({ - data: "\n", - inputValue: "word", - targetWord: "word\n", - commitCharacterType: "separator", - }), - ).toBe(true); - }); - - // the empty-input guard must not block a nospace commit on a 1-letter word, - // otherwise such words can never be advanced - it.each([ - { desc: "strictSpace on", strictSpace: true, difficulty: "normal" }, - { desc: "difficulty expert", strictSpace: false, difficulty: "expert" }, - ])( - "commits a nospace 1-letter word on empty input ($desc)", - ({ strictSpace, difficulty }) => { - replaceConfig({ strictSpace, difficulty } as any); - expect( - shouldGoToNextWord({ - data: "a", - inputValue: "", - targetWord: "a", - commitCharacterType: "nospace", - }), - ).toBe(true); - }, - ); - - describe("Logic Checks", () => { - it.each([ - // Standard behavior (submit word) - { - desc: "go to next word on correct word", - inputValue: "hello", - targetWord: "hello ", - config: { - stopOnError: "off", - strictSpace: false, - difficulty: "normal", - }, - expected: true, - }, - { - desc: "go to next word on incorrect word (stopOnError off)", - inputValue: "hel", - targetWord: "hello ", - config: { - stopOnError: "off", - strictSpace: false, - difficulty: "normal", - }, - expected: true, - }, - // Stop on error - { - desc: "stay on incorrect word (stopOnError letter)", - inputValue: "hel", - targetWord: "hello ", - config: { - stopOnError: "letter", - strictSpace: false, - difficulty: "normal", - }, - expected: false, - }, - { - desc: "stay on incorrect word (stopOnError word)", - inputValue: "hel", - targetWord: "hello ", - config: { - stopOnError: "word", - strictSpace: false, - difficulty: "normal", - }, - expected: false, - }, - { - desc: "go to next word on correct word (stopOnError letter)", - inputValue: "hello", - targetWord: "hello ", - config: { - stopOnError: "letter", - strictSpace: false, - difficulty: "normal", - }, - expected: true, - }, - // Strict space / Difficulty - { - desc: "stay on empty input (strictSpace on)", - inputValue: "", - targetWord: "hello ", - config: { - stopOnError: "off", - strictSpace: true, - difficulty: "normal", - }, - expected: false, - }, - { - desc: "stay on empty input (difficulty not normal - expert or master)", - inputValue: "", - targetWord: "hello ", - config: { - stopOnError: "off", - strictSpace: false, - difficulty: "expert", - }, - expected: false, - }, - { - desc: "go to next word on non-empty input (strictSpace on)", - inputValue: "h", - targetWord: "hello ", - config: { - stopOnError: "off", - strictSpace: true, - difficulty: "normal", - }, - expected: true, - }, - // Delete on error - { - desc: "stay on incorrect word (deleteOnError letter)", - inputValue: "hel", - targetWord: "hello ", - config: { - stopOnError: "off", - deleteOnError: "letter", - strictSpace: false, - difficulty: "normal", - }, - expected: false, - }, - { - desc: "stay on incorrect word (deleteOnError word)", - inputValue: "hel", - targetWord: "hello ", - config: { - stopOnError: "off", - deleteOnError: "word", - strictSpace: false, - difficulty: "normal", - }, - expected: false, - }, - { - desc: "go to next word on correct word (deleteOnError letter)", - inputValue: "hello", - targetWord: "hello ", - config: { - stopOnError: "off", - deleteOnError: "letter", - strictSpace: false, - difficulty: "normal", - }, - expected: true, - }, - ])("$desc", ({ inputValue, targetWord, config, expected }) => { - replaceConfig(config as any); - expect( - shouldGoToNextWord({ - data: " ", - inputValue, - targetWord, - commitCharacterType: "separator", - }), - ).toBe(expected); - }); - }); -}); diff --git a/frontend/__tests__/input/listeners/input.spec.ts b/frontend/__tests__/input/listeners/input.spec.ts index 8f6e8772da..85cfd21d91 100644 --- a/frontend/__tests__/input/listeners/input.spec.ts +++ b/frontend/__tests__/input/listeners/input.spec.ts @@ -15,7 +15,6 @@ vi.mock("../../../src/ts/input/handlers/before-delete", () => ({ })); vi.mock("../../../src/features/typing/runtime/test-ui", () => ({})); vi.mock("../../../src/features/typing/runtime/test-words", () => ({})); -vi.mock("../../../src/features/typing/runtime/words-generator", () => ({})); it("cancels unexpected events while accepting browser beforeinput events", () => { const input = document.createElement("textarea"); diff --git a/frontend/__tests__/queries/quote-ownership.spec.ts b/frontend/__tests__/queries/quote-ownership.spec.ts index 7683c70cb6..5c542aab50 100644 --- a/frontend/__tests__/queries/quote-ownership.spec.ts +++ b/frontend/__tests__/queries/quote-ownership.spec.ts @@ -1,5 +1,5 @@ import { afterEach, expect, it, vi } from "vite-plus/test"; -import type { Quote } from "@oxytype/typing-core/quotes"; +import type { Quote } from "@oxytype/web-shared/quotes"; import { getQueryClient } from "../../src/ts/queries"; import { getQuoteRatingQueryOptions } from "../../src/ts/queries/quotes"; import { diff --git a/frontend/__tests__/root/config.spec.ts b/frontend/__tests__/root/config.spec.ts index 8d0011beef..c1b174d288 100644 --- a/frontend/__tests__/root/config.spec.ts +++ b/frontend/__tests__/root/config.spec.ts @@ -1,3 +1,5 @@ +import "../__harness__/native-engine"; +import { evaluatePolicy } from "@oxytype/typing-engine/browser.client"; import { describe, it, expect, beforeEach, afterAll, vi } from "vite-plus/test"; import * as Config from "../../src/ts/config/setters"; import * as Lifecycle from "../../src/ts/config/lifecycle"; @@ -13,6 +15,7 @@ import * as ConfigValidation from "../../src/ts/config/validation"; import { configEvent } from "../../src/ts/events/config"; import * as RemoteConfig from "../../src/ts/config/sync"; import * as Notifications from "../../src/ts/states/notifications"; +import * as NativeSession from "../../src/features/typing/runtime/session.client"; import * as TestState from "../../src/ts/states/test"; import { getConfig as getConfigStore } from "../../src/ts/config/store"; @@ -57,6 +60,7 @@ describe("Config", () => { ); const miscTriggerResizeMock = vi.spyOn(window, "dispatchEvent"); const stateIsTestActiveMock = vi.spyOn(TestState, "isTestActive"); + const nativeNoQuitMock = vi.spyOn(NativeSession, "isNoQuitActive"); const mocks = [ canSetConfigWithCurrentFunboxesMock, @@ -66,6 +70,7 @@ describe("Config", () => { notificationAddMock, miscTriggerResizeMock, stateIsTestActiveMock, + nativeNoQuitMock, ]; beforeEach(async () => { @@ -76,6 +81,7 @@ describe("Config", () => { canSetConfigWithCurrentFunboxesMock.mockReturnValue(true); dbSaveConfigMock.mockResolvedValue(); stateIsTestActiveMock.mockReturnValue(true); + nativeNoQuitMock.mockReturnValue(false); replaceConfig({}); }); @@ -94,6 +100,7 @@ describe("Config", () => { it("fails if test is active and funbox no_quit", () => { //GIVEN replaceConfig({ funbox: ["no_quit"], numbers: false }); + nativeNoQuitMock.mockReturnValue(true); //WHEN expect( @@ -633,3 +640,12 @@ describe("Config", () => { }); }); }); + +vi.mock("../../src/features/typing/engine.client", () => ({ + getBrowserEngine: () => ({ + status: () => ({ state: "ready" }), + load: async () => undefined, + api: () => ({ policy: evaluatePolicy }), + session: () => undefined, + }), +})); diff --git a/frontend/__tests__/scripts/preserve-assets.spec.ts b/frontend/__tests__/scripts/preserve-assets.spec.ts index 3a36e4bf8b..6502e4d596 100644 --- a/frontend/__tests__/scripts/preserve-assets.spec.ts +++ b/frontend/__tests__/scripts/preserve-assets.spec.ts @@ -122,3 +122,58 @@ it("retains previous files in the artifact and rejects conflicting content-addre await rm(directory, { recursive: true, force: true }); } }); + +it("retains matched Wasm/glue from the public graph and verifies their bytes", async () => { + const wasm = "\0asm\u0001\0\0\0"; + const glue = + 'export const wasm = new URL("./engine-oldhash.wasm", import.meta.url);'; + const old = fixture({ + "/": '', + "/assets/engine-oldhash.js": glue, + "/assets/engine-oldhash.wasm": wasm, + }); + const previous = await snapshotAssets(origin, old.request); + expect([...previous.keys()].sort()).toEqual([ + "/assets/engine-oldhash.js", + "/assets/engine-oldhash.wasm", + ]); + const directory = await mkdtemp(resolve(tmpdir(), "oxytype-wasm-history-")); + try { + await mkdir(resolve(directory, "assets")); + await writeFile( + resolve(directory, "assets/engine-newhash.wasm"), + `${wasm}new revision`, + ); + await preserveAssets(directory, previous); + const files = JSON.parse( + await readFile(resolve(directory, "_asset-history.json"), "utf8"), + ) as { files: Record }; + expect(files.files).toMatchObject({ + "/assets/engine-oldhash.js": hash(glue), + "/assets/engine-oldhash.wasm": hash(wasm), + "/assets/engine-newhash.wasm": hash(`${wasm}new revision`), + }); + const retained = fixture({ + "/_asset-history.json": JSON.stringify(files), + "/assets/engine-oldhash.js": glue, + "/assets/engine-oldhash.wasm": wasm, + "/assets/engine-newhash.wasm": `${wasm}new revision`, + }); + expect( + (await snapshotAssets(origin, retained.request)).get( + "/assets/engine-oldhash.wasm", + ), + ).toEqual(new TextEncoder().encode(wasm)); + const corrupted = fixture({ + "/_asset-history.json": JSON.stringify({ + files: { "/assets/engine-oldhash.wasm": hash(wasm) }, + }), + "/assets/engine-oldhash.wasm": "wrong engine", + }); + await expect(snapshotAssets(origin, corrupted.request)).rejects.toThrow( + "Previous asset changed", + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/frontend/__tests__/start/service-worker-build.spec.ts b/frontend/__tests__/start/service-worker-build.spec.ts index 609b83e2cc..a20a324b6a 100644 --- a/frontend/__tests__/start/service-worker-build.spec.ts +++ b/frontend/__tests__/start/service-worker-build.spec.ts @@ -134,6 +134,39 @@ describe("built Start service worker", () => { }); }); + it("precaches the matching browser engine and delivers Wasm with compilation headers", async () => { + const files = builtConfig().precache; + expect(files.some((path) => /\/browser\.client-.+\.js$/.test(path))).toBe( + true, + ); + const wasm = files.filter((path) => path.endsWith(".wasm")); + expect(wasm).toHaveLength(1); + const runtime = await createWorkerd(); + try { + const html = await ( + await runtime.dispatchFetch("http://localhost:3000/") + ).text(); + const preload = [...html.matchAll(/]*>/g)] + .map((match) => match[0]) + .find((tag) => tag.includes(`href="${wasm[0]}"`)); + expect(preload).toContain('rel="preload"'); + expect(preload).toContain('as="fetch"'); + expect(preload).toContain('type="application/wasm"'); + expect(preload).toContain('crossorigin="anonymous"'); + const response = await runtime.dispatchFetch( + `http://localhost:3000${wasm[0]}`, + ); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("application/wasm"); + expect(response.headers.get("Cache-Control")).toContain("immutable"); + expect(new Uint8Array(await response.arrayBuffer())).toEqual( + new Uint8Array(readFileSync(resolve(client, `.${wasm[0]}`))), + ); + } finally { + await runtime.dispose(); + } + }); + it("is a self-contained classic script without backend code", () => { const source = readFileSync(resolve(client, "sw.js"), "utf8"); expect(source).not.toMatch(/\bimport\s*[({"'`]|^export\s/m); diff --git a/frontend/__tests__/test/british-english.spec.ts b/frontend/__tests__/test/british-english.spec.ts deleted file mode 100644 index 84a960b11e..0000000000 --- a/frontend/__tests__/test/british-english.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, it, expect, beforeEach } from "vite-plus/test"; -import { replace } from "../../src/features/typing/runtime/british-english"; -import { getMutableConfig } from "../../src/ts/config/store"; - -describe("british-english", () => { - describe("replace", () => { - beforeEach(() => (getMutableConfig().mode = "time")); - - it("should not replace words with no rule", async () => { - await expect(replace("test", "")).resolves.toEqual("test"); - await expect(replace("Test", "")).resolves.toEqual("Test"); - }); - - it("should replace words", async () => { - await expect(replace("math", "")).resolves.toEqual("maths"); - await expect(replace("Math", "")).resolves.toEqual("Maths"); - }); - - it("should replace words with non-word characters around", async () => { - await expect(replace(" :math-. ", "")).resolves.toEqual(" :maths-. "); - await expect(replace(" :Math-. ", "")).resolves.toEqual(" :Maths-. "); - }); - - it("should not replace in quote mode if previousWord matches excepted words", async () => { - //GIVEN - getMutableConfig().mode = "quote"; - - //WHEN/THEN - await expect(replace("tire", "will")).resolves.toEqual("tire"); - await expect(replace("tire", "")).resolves.toEqual("tyre"); - }); - - it("should replace hyphenated words", async () => { - await expect(replace("cream-colored", "")).resolves.toEqual( - "cream-coloured", - ); - await expect(replace("armor-flavoring", "")).resolves.toEqual( - "armour-flavouring", - ); - }); - - it("should convert double quotes to single quotes", async () => { - await expect(replace('"hello"', "")).resolves.toEqual("'hello'"); - await expect(replace('"test"', "")).resolves.toEqual("'test'"); - await expect(replace('"Hello World"', "")).resolves.toEqual( - "'Hello World'", - ); - }); - - it("should convert double quotes and replace words", async () => { - await expect(replace('"color"', "")).resolves.toEqual("'colour'"); - await expect(replace('"math"', "")).resolves.toEqual("'maths'"); - await expect(replace('"Color"', "")).resolves.toEqual("'Colour'"); - }); - - it("should handle multiple double quotes in a word", async () => { - await expect( - replace('He said "hello" and "goodbye"', ""), - ).resolves.toEqual("He said 'hello' and 'goodbye'"); - }); - - it("should not affect words without double quotes", async () => { - await expect(replace("'hello'", "")).resolves.toEqual("'hello'"); - await expect(replace("test", "")).resolves.toEqual("test"); - }); - - it("ignores prototype-related property names (e.g. constructor, __proto__)", async () => { - await expect(replace("constructor", "")).resolves.toEqual("constructor"); - await expect(replace("__proto__", "")).resolves.toEqual("__proto__"); - }); - }); -}); diff --git a/frontend/__tests__/test/characterization/lifecycle.spec.ts b/frontend/__tests__/test/characterization/lifecycle.spec.ts index dbdfa18662..24450465e1 100644 --- a/frontend/__tests__/test/characterization/lifecycle.spec.ts +++ b/frontend/__tests__/test/characterization/lifecycle.spec.ts @@ -1,6 +1,6 @@ -import { createRoot } from "solid-js"; +import { createRoot, onCleanup } from "solid-js"; import type { LanguageObject } from "@oxytype/schemas/languages"; -import type { EventLog } from "@oxytype/typing-core/events/types"; +import type { EventLog } from "@oxytype/web-shared/events/types"; import { afterEach, beforeEach, @@ -14,11 +14,13 @@ import { // timer and completion code. Only rendering, storage and transport are mocked. const mocks = vi.hoisted(() => ({ + customIndicator: undefined as { name: string; isLong: boolean } | undefined, + bailedOut: false, active: false, restarting: false, calculating: false, wordIndex: 0, - generatedWords: ["hello ", "world "], + generatedWords: ["hello ", "world ", "hello ", "world ", "last"], liveStats: [] as Record[], incomplete: vi.fn(), resultUpdate: vi.fn(), @@ -33,6 +35,22 @@ const mocks = vi.hoisted(() => ({ paceLastWpm: vi.fn(), todaySeconds: vi.fn(), lowFps: vi.fn(), + session: undefined as + | import("@oxytype/typing-engine/browser.client").BrowserSession + | undefined, + generation: 0, +})); + +vi.mock("../../../src/features/typing/engine.client", () => ({ + getBrowserEngine: () => ({ + load: async () => undefined, + session: () => mocks.session, + invalidateSession: () => { + mocks.generation++; + }, + generation: () => mocks.generation, + api: () => ({ analyze: analyzeLog }), + }), })); vi.mock("../../../src/ts/states/test", () => ({ @@ -54,7 +72,7 @@ vi.mock("../../../src/ts/states/test", () => ({ getActiveWordIndex: () => mocks.wordIndex, resetActiveWordIndex: vi.fn(), getResultVisible: () => false, - getBailedOut: () => false, + getBailedOut: () => mocks.bailedOut, getKoreanStatus: () => false, isPaceRepeat: () => false, getIncompleteSeconds: () => 0, @@ -84,7 +102,7 @@ vi.mock("../../../src/ts/states/test", () => ({ }, })); vi.mock("../../../src/ts/states/core", () => ({ - getCustomTextIndicator: () => undefined, + getCustomTextIndicator: () => mocks.customIndicator, isAuthenticated: () => false, })); vi.mock("../../../src/ts/auth-client", () => ({ @@ -105,15 +123,21 @@ vi.mock("../../../src/features/typing/runtime/test-ui", () => ({ appendEmptyWordElement: vi.fn(), addWord: vi.fn(), })); -vi.mock("../../../src/features/typing/runtime/words-generator", () => ({ - generateWords: async () => ({ - words: [...mocks.generatedWords], - sectionIndexes: mocks.generatedWords.map(() => 0), - hasTab: false, - hasNewline: false, +vi.mock("../../../src/features/typing/runtime/content.client", () => ({ + getContentHost: () => ({ + prepare: async () => { + mocks.session?.dispose(); + mocks.session = nativeSession(getMutableConfig(), mocks.generatedWords); + const session = getEngineSession(); + session.attach(); + return { + words: [...mocks.generatedWords], + sectionIndexes: mocks.generatedWords.map(() => 0), + hasTab: false, + hasNewline: false, + }; + }, }), - areAllWordsGenerated: () => true, - appendCommitCharacter: (word: string) => `${word} `, })); vi.mock("../../../src/features/typing/runtime/funbox/list", () => ({ getActiveFunboxes: () => [], @@ -228,8 +252,12 @@ import { getMutableConfig } from "../../../src/ts/config/store"; import { getDefaultConfig } from "../../../src/ts/constants/default-config"; import { getAllTestEvents, - logTestEvent, + getTestSession, } from "../../../src/features/typing/runtime/events/data"; +import { nativeSession } from "../../__harness__/native-engine"; +import { getEngineSession } from "../../../src/features/typing/runtime/session.client"; +import { analyzeLog } from "@oxytype/typing-engine/browser.client"; +import { encodeText } from "@oxytype/typing-engine/text"; import * as SlowTimer from "../../../src/ts/states/slow-timer"; import * as CustomText from "../../../src/features/typing/runtime/custom-text"; import { @@ -240,17 +268,34 @@ import { function type( now: number, - wordIndex: number, + _wordIndex: number, typed: string, - correct = true, + _correct = true, ): void { - logTestEvent("input", now, { - inputType: "insertText", - data: typed.at(-1) ?? "", - correct, - wordIndex, - charIndex: typed.length - 1, - inputValue: typed, + const session = getEngineSession(); + const before = session.getCurrentInput(); + let prefix = 0; + while (prefix < before.length && before[prefix] === typed[prefix]) { + prefix++; + } + if (prefix < before.length) { + session.command({ + op: "delete", + now, + date: Date.now(), + kind: "deleteContentBackward", + observedInput: encodeText(before.slice(0, prefix)), + sentinelRemoved: false, + }); + } + session.command({ + op: "insert", + now, + date: Date.now(), + data: encodeText(typed.slice(prefix)), + correctShiftUsed: null, + automatic: null, + isCompositionEnding: null, }); } @@ -270,7 +315,11 @@ function failReason(): unknown { } let disposeRuntime: () => void; -afterEach(() => disposeRuntime()); +afterEach(() => { + disposeRuntime(); + mocks.session?.dispose(); + mocks.session = undefined; +}); beforeEach(async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance", "Date"], @@ -278,16 +327,26 @@ beforeEach(async () => { vi.clearAllMocks(); disposeRuntime = createRoot((dispose) => { useTestLogicEvents(); + const unsubscribe = getEngineSession().subscribe((update) => { + mocks.wordIndex = update.state.wordIndex; + if (update.slowTimer) { + SlowTimer.set(); + mocks.lowFps(); + } + }); + onCleanup(unsubscribe); return dispose; }); Object.assign(mocks, { + customIndicator: undefined, + bailedOut: false, active: false, restarting: false, calculating: false, wordIndex: 0, liveStats: [], lastEventLog: undefined, - generatedWords: ["hello ", "world "], + generatedWords: ["hello ", "world ", "hello ", "world ", "last"], }); mocks.resultUpdate.mockResolvedValue(undefined); Object.assign(getMutableConfig(), getDefaultConfig(), { @@ -392,6 +451,52 @@ describe("completion", () => { expect(timerEvents()).toHaveLength(5); }); + it.each([ + { + name: "partial bailout", + data: "alpha b", + bailout: true, + progress: 2, + remaining: ["beta", "gamma"], + }, + { + name: "complete tail", + data: "alpha beta gamma", + bailout: false, + progress: 0, + remaining: ["old", "alpha", "beta", "gamma"], + }, + ])( + "persists native long-text progress after $name", + async ({ data, bailout, progress, remaining }) => { + mocks.customIndicator = { name: "fixture_book", isLong: true }; + mocks.generatedWords = ["alpha ", "beta ", "gamma"]; + getMutableConfig().mode = "custom"; + CustomText.setCustomText( + "fixture_book", + ["old", "alpha", "beta", "gamma"], + true, + ); + CustomText.setCustomTextLongProgress("fixture_book", 1); + CustomText.setText(["alpha", "beta", "gamma"]); + CustomText.setLimitMode("word"); + CustomText.setLimitValue(3); + await restart(); + startTest(performance.now()); + vi.advanceTimersByTime(100); + type(performance.now(), 0, data); + if (bailout) { + mocks.bailedOut = true; + getEngineSession().bailout(performance.now()); + } + await vi.waitFor(() => expect(mocks.resultUpdate).toHaveBeenCalled()); + expect(CustomText.getCustomTextLongProgress("fixture_book")).toBe( + progress, + ); + expect(CustomText.getText()).toEqual(remaining); + }, + ); + it("counts custom time limits", async () => { getMutableConfig().mode = "custom"; CustomText.setLimitMode("time"); @@ -452,10 +557,28 @@ describe("failure", () => { const start = performance.now(); startTest(start); type(start + 100, 0, "h"); - mocks.wordIndex = 3; + for (let index = 0; index < 3; index++) { + getTestSession().command({ + op: "insert", + now: start + 300, + date: Date.now(), + data: [120, 32], + correctShiftUsed: null, + automatic: null, + isCompositionEnding: null, + }); + } vi.advanceTimersByTime(1000); expect(mocks.resultUpdate).not.toHaveBeenCalled(); - mocks.wordIndex = 4; + getTestSession().command({ + op: "insert", + now: start + 1200, + date: Date.now(), + data: [120, 32], + correctShiftUsed: null, + automatic: null, + isCompositionEnding: null, + }); vi.advanceTimersByTime(1000); await vi.waitFor(() => expect(mocks.resultUpdate).toHaveBeenCalled()); expect(failReason()).toBe("min speed"); diff --git a/frontend/__tests__/test/characterization/pace-caret.spec.ts b/frontend/__tests__/test/characterization/pace-caret.spec.ts index 1e926764f8..dede895ed1 100644 --- a/frontend/__tests__/test/characterization/pace-caret.spec.ts +++ b/frontend/__tests__/test/characterization/pace-caret.spec.ts @@ -25,8 +25,18 @@ const mocks = vi.hoisted(() => ({ hidden: true, goTo: [] as GoTo[], localPb: undefined as { wpm: number } | undefined, + session: undefined as + | import("@oxytype/typing-engine/browser.client").BrowserSession + | undefined, })); +vi.mock("../../../src/features/typing/engine.client", () => ({ + getBrowserEngine: () => ({ + session: () => mocks.session, + generation: () => 0, + invalidateSession: () => undefined, + }), +})); vi.mock("../../../src/ts/states/test", () => ({ isTestActive: () => mocks.active, getResultVisible: () => mocks.resultVisible, @@ -72,11 +82,35 @@ vi.mock("../../../src/features/typing/runtime/funbox/list", () => ({ getActiveFunboxes: () => [], })); +import { nativeSession } from "../../__harness__/native-engine"; +import { getEngineSession } from "../../../src/features/typing/runtime/session.client"; +import { encodeText } from "@oxytype/typing-engine/text"; import { getMutableConfig } from "../../../src/ts/config/store"; import { getDefaultConfig } from "../../../src/ts/constants/default-config"; import * as PaceCaret from "../../../src/features/typing/runtime/pace-caret"; import * as TestWords from "../../../src/features/typing/runtime/test-words"; +function commit(text: string): void { + getEngineSession().command({ + op: "insert", + data: encodeText(text), + now: performance.now(), + date: 0, + correctShiftUsed: null, + automatic: null, + isCompositionEnding: null, + }); +} +function back(): void { + getEngineSession().command({ + op: "delete", + kind: "deleteWordBackward", + now: performance.now(), + date: 0, + observedInput: null, + sentinelRemoved: false, + }); +} function positions(): string[] { return mocks.goTo .filter((call) => call.animate) @@ -106,9 +140,14 @@ beforeEach(() => { TestWords.words.push("hi ", 0); TestWords.words.push("you ", 0); TestWords.words.push("all", 0); + mocks.session = nativeSession(getMutableConfig(), ["hi ", "you ", "all"]); + getEngineSession().attach(); + getEngineSession().start(performance.now()); }); afterEach(() => { PaceCaret.reset(); + mocks.session?.dispose(); + mocks.session = undefined; vi.useRealTimers(); }); @@ -148,11 +187,13 @@ describe("pace caret", () => { it("jumps ahead by the length of a mistyped word and back when corrected", async () => { await PaceCaret.init(); PaceCaret.start(); - PaceCaret.handleSpace(false, "hi "); + commit("x "); vi.advanceTimersByTime(200); expect(positions().at(-1)).toBe("1:2"); - PaceCaret.handleSpace(false, "hi "); - PaceCaret.handleSpace(true, "hi "); + back(); + commit("x "); + back(); + commit("hi "); vi.advanceTimersByTime(200); expect(positions().at(-1)).toBe("1:0"); }); @@ -161,7 +202,7 @@ describe("pace caret", () => { getMutableConfig().blindMode = true; await PaceCaret.init(); PaceCaret.start(); - PaceCaret.handleSpace(false, "hi "); + commit("x "); vi.advanceTimersByTime(200); expect(positions()).toEqual(["0:1", "0:2"]); }); diff --git a/frontend/__tests__/test/characterization/replay.spec.ts b/frontend/__tests__/test/characterization/replay.spec.ts index bfc61f8eb4..1d8586f7e4 100644 --- a/frontend/__tests__/test/characterization/replay.spec.ts +++ b/frontend/__tests__/test/characterization/replay.spec.ts @@ -1,7 +1,7 @@ import { readFileSync } from "fs"; import { resolve } from "path"; import { fileURLToPath } from "url"; -import type { EventLog } from "@oxytype/typing-core/events/types"; +import type { EventLog } from "@oxytype/web-shared/events/types"; import { afterEach, beforeEach, @@ -22,10 +22,23 @@ vi.mock("../../../src/ts/controllers/sound-controller", () => ({ import { getMutableConfig } from "../../../src/ts/config/store"; import { getDefaultConfig } from "../../../src/ts/constants/default-config"; import { resultState, setResultState } from "../../../src/ts/states/result"; +import "../../__harness__/native-engine"; import { - logTestEvent, - resetTestEvents, -} from "../../../src/features/typing/runtime/events/data"; + analyzeLog, + BrowserReplay, +} from "@oxytype/typing-engine/browser.client"; +import { toEngineLog } from "@oxytype/typing-engine/text"; +const recorded = vi.hoisted(() => ({ log: undefined as EventLog | undefined })); +vi.mock("../../../src/features/typing/engine.client", () => ({ + getBrowserEngine: () => ({ + api: () => ({ + analyze: analyzeLog, + createReplay: (log: Parameters[0], zen: boolean) => + new BrowserReplay(log, zen), + }), + session: () => ({ log: () => toEngineLog(recorded.log as EventLog) }), + }), +})); import * as Replay from "../../../src/features/typing/runtime/replay"; import * as TestWords from "../../../src/features/typing/runtime/test-words"; @@ -33,21 +46,26 @@ const fixture = JSON.parse( readFileSync( resolve( fileURLToPath(import.meta.url), - "../../../../../packages/typing-core/__fixtures__/keystrokes/words-10-errors.json", + "../../../../../packages/typing-fixtures/reference/keystrokes/words-10-errors.json", ), "utf-8", ), ) as { eventLog: EventLog }; -const START = 10_000; - function load(words: string[], events: EventLog["events"]): void { TestWords.words.reset(); for (const word of words) TestWords.words.push(word, 0); - resetTestEvents(); - for (const event of events) { - logTestEvent(event.type, START + event.testMs, event.data); - } + recorded.log = { + version: 1, + context: { + targetWords: words, + mode: getMutableConfig().mode, + mode2: String(words.length), + koreanStatus: false, + bailedOut: false, + }, + events, + }; } function render(): string[] { diff --git a/frontend/__tests__/test/events/anticheat.spec.ts b/frontend/__tests__/test/events/anticheat.spec.ts index 8cb1c6502d..afab0fdbdb 100644 --- a/frontend/__tests__/test/events/anticheat.spec.ts +++ b/frontend/__tests__/test/events/anticheat.spec.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from "vite-plus/test"; import type { CompletedEvent } from "@oxytype/schemas/results"; -import { kogasa, mean, roundTo2, stdDev } from "@oxytype/util/numbers"; import { completedEvent } from "../../../../backend/__tests__/__testData__/completed-event"; import { humanTimings } from "../../../../backend/__tests__/__testData__/key-timings"; import { @@ -10,25 +9,10 @@ import { getTimingFingerprint, getTimingReview, } from "../../../../backend/src/anticheat"; -import { calculateWpm } from "@oxytype/typing-core/stats-math"; -import type { - EventLog, - TestEventNoMs, -} from "@oxytype/typing-core/events/types"; -import { - getChars, - getAccuracy, - getTestDurationMs, - getAfkDuration, - getKeypressSpacing, - getKeypressDurations, - getKeypressOverlap, - getLastKeypressToEndMs, - getStartToFirstKeypressMs, - getWpmHistory, - getBurstHistory, - getErrorCountHistory, -} from "@oxytype/typing-core/events/stats"; +import type { EventLog, TestEventNoMs } from "@oxytype/web-shared/events/types"; +import "../../__harness__/native-engine"; +import { analyzeLog } from "@oxytype/typing-engine/browser.client"; +import { toEngineLog } from "@oxytype/typing-engine/text"; vi.mock("../../../src/ts/config/store", () => ({ getMutableConfig: (() => { @@ -166,36 +150,15 @@ function timedEventLog(gaps: number[], holds: number[]): EventLog { /** Build from the real client event reducers, not backend validator helpers. */ function payload(log: EventLog): CompletedEvent { - const chars = getChars(log); - const testDuration = getTestDurationMs(log) / 1000; - const keySpacing = getKeypressSpacing(log); - const wpm = getWpmHistory(log); - const burst = getBurstHistory(log); - const consistency = (values: number[]): number => { - const value = roundTo2(kogasa(stdDev(values) / mean(values))); - return Number.isNaN(value) ? 0 : value; - }; - const charTotal = chars.allCorrect + chars.incorrect + chars.extra; + const { summary } = analyzeLog(toEngineLog(log), { + op: "stats", + now: 0, + }).stats; return completedEvent({ + ...summary, mode: log.context.mode, mode2: log.context.mode2, bailedOut: log.context.bailedOut, - testDuration, - afkDuration: getAfkDuration(log), - charStats: [chars.correctWord, chars.incorrect, chars.extra, chars.missed], - charTotal, - wpm: roundTo2(calculateWpm(chars.correctWord, testDuration)), - rawWpm: roundTo2(calculateWpm(charTotal, testDuration)), - acc: roundTo2(getAccuracy(log).percentage), - keySpacing, - keyDuration: getKeypressDurations(log), - keyOverlap: getKeypressOverlap(log), - keyConsistency: consistency(keySpacing.slice(0, -1)), - startToFirstKey: getStartToFirstKeypressMs(log), - lastKeyToEnd: getLastKeypressToEndMs(log), - chartData: { wpm, burst, err: getErrorCountHistory(log) }, - consistency: consistency(burst), - wpmConsistency: consistency(wpm), }); } diff --git a/frontend/__tests__/test/events/data.spec.ts b/frontend/__tests__/test/events/data.spec.ts deleted file mode 100644 index b7e8b18de6..0000000000 --- a/frontend/__tests__/test/events/data.spec.ts +++ /dev/null @@ -1,565 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from "vite-plus/test"; - -vi.mock("../../../src/features/typing/runtime/test-stats", () => ({ - start: 1000, -})); - -import { - logTestEvent, - getAllTestEvents, - cleanupData, - resetTestEvents, - getTestSession, - forceReleaseAllKeys, -} from "../../../src/features/typing/runtime/events/data"; -import type { - InputEventData, - KeydownEvent, - KeydownEventData, - KeyupEvent, - KeyupEventData, - TimerEventData, -} from "@oxytype/typing-core/events/types"; -import { Keycode } from "../../../src/ts/constants/keys"; -import { getEventsPerWord } from "@oxytype/typing-core/events/helpers"; - -function keyDown(code: Keycode | "NoCode" = "KeyA"): KeydownEventData { - return { code }; -} - -function keyUp(code: Keycode | "NoCode" = "KeyA"): KeyupEventData { - return { code }; -} - -function inputData( - overrides: Partial<{ - charIndex: number; - wordIndex: number; - data: string; - correct: boolean; - inputType: string; - }> = {}, -): InputEventData { - return { - charIndex: 0, - wordIndex: 0, - inputType: "insertText", - data: "a", - correct: true, - ...overrides, - } as InputEventData; -} - -function timerData( - event: "start" | "step" | "end", - timer: number, -): TimerEventData { - if (event === "step") { - return { event, timer, drift: 0 }; - } - return { event, timer, date: 0 }; -} - -describe("data.ts", () => { - beforeEach(() => { - resetTestEvents(); - getTestSession().recorder.__testing.resetPressedKeys(); - }); - - describe("logTestEvent + getAllTestEvents", () => { - it("returns empty array when no events logged", () => { - expect(getAllTestEvents()).toEqual([]); - }); - - it("logs and retrieves events sorted by ms", () => { - logTestEvent("input", 1050, inputData()); - logTestEvent("keydown", 1030, keyDown()); - logTestEvent("timer", 1020, timerData("start", 0)); - - const events = getAllTestEvents(); - expect(events).toHaveLength(3); - expect(events[0]?.type).toBe("timer"); - expect(events[1]?.type).toBe("keydown"); - expect(events[2]?.type).toBe("input"); - }); - - it("input events with the same ms as timer end are kept", () => { - logTestEvent("timer", 1000, timerData("start", 0)); - logTestEvent("timer", 2000, timerData("end", 1)); - logTestEvent("input", 2000, inputData()); - - cleanupData(); - const events = getAllTestEvents(); - const inputs = events.filter((e) => e.type === "input"); - expect(inputs).toHaveLength(1); - }); - - it("strips undefined values from eventData", () => { - logTestEvent("input", 1100, { - charIndex: 0, - wordIndex: 0, - inputType: "deleteWordBackward", - inputValue: "", - clearedNextWord: undefined, - } as unknown as InputEventData); - - const stored = getAllTestEvents()[0]?.data as Record; - expect("clearedNextWord" in stored).toBe(false); - expect(stored["inputValue"]).toBe(""); - }); - - it("caches getAllTestEvents and invalidates on new event", () => { - logTestEvent("timer", 1100, timerData("start", 0)); - const first = getAllTestEvents(); - const second = getAllTestEvents(); - expect(first).toBe(second); // same reference = cached - - logTestEvent("timer", 2100, timerData("end", 1)); - const third = getAllTestEvents(); - expect(third).not.toBe(first); // new reference = invalidated - }); - }); - - describe("logTestEvent keydown filtering", () => { - it("ignores keys not in keysToTrack", () => { - logTestEvent("keydown", 1010, keyDown("Backspace")); - expect(getAllTestEvents()).toHaveLength(0); - }); - - it("synthesizes missing keyup on duplicate keydown", () => { - logTestEvent("keydown", 1010, keyDown()); - logTestEvent("keydown", 1020, keyDown()); - const events = getAllTestEvents(); - expect(events).toHaveLength(3); - expect(events[0]?.type).toBe("keydown"); - expect(events[1]?.type).toBe("keyup"); - expect(events[2]?.type).toBe("keydown"); - }); - - it("allows keydown after keyup", () => { - logTestEvent("keydown", 1010, keyDown()); - logTestEvent("keyup", 1020, keyUp()); - logTestEvent("keydown", 1030, keyDown()); - expect(getAllTestEvents()).toHaveLength(3); - }); - }); - - describe("logTestEvent keyup filtering", () => { - it("ignores keyup for untracked keys", () => { - logTestEvent("keyup", 1010, keyUp("Backspace")); - expect(getAllTestEvents()).toHaveLength(0); - }); - - it("ignores keyup without prior keydown", () => { - logTestEvent("keyup", 1010, keyUp()); - expect(getAllTestEvents()).toHaveLength(0); - }); - }); - - describe("NoCode handling", () => { - it("tracks multiple simultaneous NoCode keydowns", () => { - logTestEvent("keydown", 1010, keyDown("NoCode")); - logTestEvent("keydown", 1020, keyDown("NoCode")); - - const events = getAllTestEvents(); - expect(events).toHaveLength(2); - }); - - it("tracks NoCode keyup after keydown", () => { - logTestEvent("keydown", 1010, keyDown("NoCode")); - logTestEvent("keyup", 1020, keyUp("NoCode")); - - const events = getAllTestEvents(); - expect(events).toHaveLength(2); - expect(events[0]?.type).toBe("keydown"); - expect(events[1]?.type).toBe("keyup"); - }); - - it("stores indexed code on keydown events", () => { - logTestEvent("keydown", 1010, keyDown("NoCode")); - logTestEvent("keydown", 1020, keyDown("NoCode")); - - const events = getAllTestEvents() as KeydownEvent[]; - expect(events[0]?.data.code).toBe("NoCode0"); - expect(events[1]?.data.code).toBe("NoCode1"); - }); - - it("stores matching indexed code on keyup events", () => { - logTestEvent("keydown", 1010, keyDown("NoCode")); - logTestEvent("keydown", 1020, keyDown("NoCode")); - logTestEvent("keyup", 1030, keyUp("NoCode")); - logTestEvent("keyup", 1040, keyUp("NoCode")); - - const events = getAllTestEvents(); - // keyups are LIFO — second keydown (NoCode1) is released first - expect((events[2] as KeyupEvent).data.code).toBe("NoCode1"); - expect((events[3] as KeyupEvent).data.code).toBe("NoCode0"); - }); - - it("ignores NoCode keyup when no matching keydown exists", () => { - logTestEvent("keyup", 1010, keyUp("NoCode")); - - expect(getAllTestEvents()).toHaveLength(0); - }); - - it("stray NoCode keyup does not corrupt noCodeIndex", () => { - // stray keyup with no matching keydown - logTestEvent("keyup", 1010, keyUp("NoCode")); - - // subsequent keydown/keyup should still work correctly - logTestEvent("keydown", 1020, keyDown("NoCode")); - logTestEvent("keyup", 1030, keyUp("NoCode")); - - const events = getAllTestEvents(); - expect(events).toHaveLength(2); - expect((events[0] as KeydownEvent).data.code).toBe("NoCode0"); - expect((events[1] as KeyupEvent).data.code).toBe("NoCode0"); - }); - - it("accepts already-indexed NoCode keyup", () => { - logTestEvent("keydown", 1010, keyDown("NoCode")); - logTestEvent("keydown", 1020, keyDown("NoCode")); - - // simulate forceReleaseAllKeys passing indexed codes directly - logTestEvent("keyup", 1030, { - code: "NoCode0", - }); - logTestEvent("keyup", 1040, { - code: "NoCode1", - }); - - const events = getAllTestEvents(); - expect(events).toHaveLength(4); - const keyups = events.filter((e) => e.type === "keyup"); - expect(keyups).toHaveLength(2); - expect((keyups[0] as KeyupEvent).data.code).toBe("NoCode0"); - expect((keyups[1] as KeyupEvent).data.code).toBe("NoCode1"); - }); - - it("rejects indexed NoCode keyup with no matching keydown", () => { - logTestEvent("keyup", 1010, { - code: "NoCode0", - }); - - expect(getAllTestEvents()).toHaveLength(0); - }); - }); - - describe("getEventsPerWord", () => { - it("groups input events by wordIndex", () => { - logTestEvent("input", 1010, inputData({ wordIndex: 0, charIndex: 0 })); - logTestEvent("input", 1020, inputData({ wordIndex: 0, charIndex: 1 })); - logTestEvent("input", 1030, inputData({ wordIndex: 1, charIndex: 0 })); - - const perWord = getEventsPerWord(getAllTestEvents()); - expect(perWord.get(0)).toHaveLength(2); - expect(perWord.get(1)).toHaveLength(1); - }); - - it("includes composition events alongside input events", () => { - logTestEvent("input", 1010, inputData({ wordIndex: 0, charIndex: 0 })); - logTestEvent("composition", 1020, { event: "start", wordIndex: 0 }); - logTestEvent("keydown", 1025, keyDown()); - logTestEvent("composition", 1030, { - event: "update", - data: "a", - wordIndex: 0, - }); - - const perWord = getEventsPerWord(getAllTestEvents()); - expect(perWord.get(0)).toHaveLength(3); - }); - - it("does not shift delete at charIndex 0 if wordIndex is 0", () => { - logTestEvent("input", 1010, { - charIndex: 0, - wordIndex: 0, - inputType: "deleteContentBackward", - } as InputEventData); - - const perWord = getEventsPerWord(getAllTestEvents()); - expect(perWord.get(0)).toHaveLength(1); - }); - - it("respects testMsLimit", () => { - logTestEvent("input", 1010, inputData({ wordIndex: 0, charIndex: 0 })); - logTestEvent("input", 1100, inputData({ wordIndex: 0, charIndex: 1 })); - - const perWord = getEventsPerWord(getAllTestEvents(), 50); - expect(perWord.get(0)).toHaveLength(1); - }); - }); - - describe("cleanupData", () => { - describe("pre-start filtering", () => { - it("removes all pre-start keydowns except the last", () => { - logTestEvent("keydown", 900, keyDown("KeyA")); - logTestEvent("keyup", 910, keyUp("KeyA")); - logTestEvent("keydown", 950, keyDown("KeyS")); - logTestEvent("timer", 1000, timerData("start", 0)); - logTestEvent("keyup", 1100, keyUp("KeyS")); - - cleanupData(); - const events = getAllTestEvents(); - const keydowns = events.filter((e) => e.type === "keydown"); - expect(keydowns).toHaveLength(1); - expect((keydowns[0] as KeydownEvent).data.code).toBe("KeyS"); - }); - - it("removes all pre-start keyups", () => { - logTestEvent("keydown", 900, keyDown("KeyA")); - logTestEvent("keyup", 910, keyUp("KeyA")); - logTestEvent("timer", 1000, timerData("start", 0)); - - cleanupData(); - const events = getAllTestEvents(); - const keyups = events.filter((e) => e.type === "keyup"); - expect(keyups).toHaveLength(0); - }); - - it("keeps pre-start non-key events (timer, input)", () => { - logTestEvent("input", 900, inputData()); - logTestEvent("keydown", 950, keyDown("KeyA")); - logTestEvent("timer", 1000, timerData("start", 0)); - - cleanupData(); - const events = getAllTestEvents(); - expect(events.filter((e) => e.type === "input")).toHaveLength(1); - }); - - it("does nothing when no timer start exists", () => { - logTestEvent("keydown", 1000, keyDown("KeyA")); - logTestEvent("keyup", 1050, keyUp("KeyA")); - logTestEvent("keydown", 1100, keyDown("KeyS")); - logTestEvent("keyup", 1150, keyUp("KeyS")); - - cleanupData(); - expect(getAllTestEvents()).toHaveLength(4); - }); - - it("removes all pre-start keydowns when there are none", () => { - logTestEvent("timer", 1000, timerData("start", 0)); - logTestEvent("keydown", 1100, keyDown("KeyA")); - - cleanupData(); - const events = getAllTestEvents(); - expect(events.filter((e) => e.type === "keydown")).toHaveLength(1); - }); - }); - - describe("post-end filtering", () => { - it("removes input events after timer end", () => { - logTestEvent("timer", 1000, timerData("start", 0)); - logTestEvent("input", 1100, inputData()); - logTestEvent("timer", 2000, timerData("end", 1)); - logTestEvent("input", 2100, inputData({ charIndex: 1 })); - - cleanupData(); - const events = getAllTestEvents(); - const inputs = events.filter((e) => e.type === "input"); - expect(inputs).toHaveLength(1); - }); - - it("removes keydowns after timer end", () => { - logTestEvent("timer", 1000, timerData("start", 0)); - logTestEvent("keydown", 1100, keyDown("KeyA")); - logTestEvent("keyup", 1200, keyUp("KeyA")); - logTestEvent("timer", 2000, timerData("end", 1)); - logTestEvent("keydown", 2100, keyDown("KeyS")); - logTestEvent("keyup", 2200, keyUp("KeyS")); - - cleanupData(); - const events = getAllTestEvents(); - const keydowns = events.filter((e) => e.type === "keydown"); - expect(keydowns).toHaveLength(1); - expect((keydowns[0] as KeydownEvent).data.code).toBe("KeyA"); - }); - - it("removes keyups associated with post-end keydowns", () => { - logTestEvent("timer", 1000, timerData("start", 0)); - logTestEvent("timer", 2000, timerData("end", 1)); - logTestEvent("keydown", 2100, keyDown("KeyA")); - logTestEvent("keyup", 2200, keyUp("KeyA")); - - cleanupData(); - const events = getAllTestEvents(); - expect(events.filter((e) => e.type === "keyup")).toHaveLength(0); - }); - - it("keeps keyups for keydowns that started before timer end", () => { - logTestEvent("timer", 1000, timerData("start", 0)); - logTestEvent("keydown", 1900, keyDown("KeyA")); - logTestEvent("timer", 2000, timerData("end", 1)); - logTestEvent("keyup", 2100, keyUp("KeyA")); - - cleanupData(); - const events = getAllTestEvents(); - const keyups = events.filter((e) => e.type === "keyup"); - expect(keyups).toHaveLength(1); - expect((keyups[0] as KeyupEvent).data.code).toBe("KeyA"); - }); - - it("keeps timer end event itself", () => { - logTestEvent("timer", 1000, timerData("start", 0)); - logTestEvent("timer", 2000, timerData("end", 1)); - - cleanupData(); - const events = getAllTestEvents(); - expect(events).toHaveLength(2); - }); - - it("does nothing when no timer end exists", () => { - logTestEvent("timer", 1000, timerData("start", 0)); - logTestEvent("keydown", 1100, keyDown("KeyA")); - logTestEvent("input", 1200, inputData()); - - cleanupData(); - expect(getAllTestEvents()).toHaveLength(3); - }); - }); - - describe("source array sync", () => { - it("cleanup persists after cache invalidation", () => { - logTestEvent("keydown", 900, keyDown("KeyA")); - logTestEvent("keyup", 910, keyUp("KeyA")); - logTestEvent("keydown", 950, keyDown("KeyS")); - logTestEvent("timer", 1000, timerData("start", 0)); - logTestEvent("keyup", 1100, keyUp("KeyS")); - logTestEvent("timer", 2000, timerData("end", 1)); - logTestEvent("input", 2100, inputData({ charIndex: 1 })); - logTestEvent("keydown", 2200, keyDown("KeyD")); - logTestEvent("keyup", 2300, keyUp("KeyD")); - - cleanupData(); - - // simulate cache invalidation + rebuild by logging a new event - logTestEvent("timer", 2500, timerData("step", 2)); - const events = getAllTestEvents(); - - // pre-start KeyA keydown/keyup should still be gone - const keydowns = events.filter((e) => e.type === "keydown"); - expect(keydowns).toHaveLength(1); - expect((keydowns[0] as KeydownEvent).data.code).toBe("KeyS"); - - // post-end input and KeyD keydown/keyup should still be gone - const inputs = events.filter((e) => e.type === "input"); - expect(inputs).toHaveLength(0); - expect( - events.filter((e) => e.type === "keydown" && e.data.code === "KeyD"), - ).toHaveLength(0); - }); - }); - - describe("combined pre-start and post-end", () => { - it("filters both pre-start and post-end events", () => { - logTestEvent("keydown", 900, keyDown("KeyA")); - logTestEvent("keyup", 910, keyUp("KeyA")); - logTestEvent("keydown", 950, keyDown("KeyS")); - logTestEvent("timer", 1000, timerData("start", 0)); - logTestEvent("input", 1100, inputData()); - logTestEvent("keyup", 1200, keyUp("KeyS")); - logTestEvent("timer", 2000, timerData("end", 1)); - logTestEvent("input", 2100, inputData({ charIndex: 1 })); - logTestEvent("keydown", 2200, keyDown("KeyD")); - logTestEvent("keyup", 2300, keyUp("KeyD")); - - cleanupData(); - const events = getAllTestEvents(); - - // pre-start: only last keydown (KeyS) kept, keyup removed - // post-end: input, keydown (KeyD), keyup (KeyD) removed - const keydowns = events.filter((e) => e.type === "keydown"); - expect(keydowns).toHaveLength(1); - expect((keydowns[0] as KeydownEvent).data.code).toBe("KeyS"); - - const inputs = events.filter((e) => e.type === "input"); - expect(inputs).toHaveLength(1); - - const keyups = events.filter((e) => e.type === "keyup"); - expect(keyups).toHaveLength(1); - expect((keyups[0] as KeyupEvent).data.code).toBe("KeyS"); - }); - }); - }); - - describe("resetTestEvents", () => { - it("clears all events", () => { - logTestEvent("keydown", 1010, keyDown()); - logTestEvent("input", 1020, inputData()); - logTestEvent("timer", 1030, timerData("start", 0)); - - resetTestEvents(); - expect(getAllTestEvents()).toEqual([]); - }); - }); - - describe("forceReleaseAllKeys", () => { - it("creates synthetic keyup events for pressed keys", () => { - logTestEvent("timer", 1000, timerData("start", 0)); - logTestEvent("keydown", 1100, keyDown("KeyA")); - logTestEvent("keyup", 1180, keyUp("KeyA")); - // KeyS is still held - logTestEvent("keydown", 1200, keyDown("KeyS")); - - forceReleaseAllKeys(); - - const events = getAllTestEvents(); - const keyups = events.filter( - (e) => e.type === "keyup" && e.data.code === "KeyS", - ); - expect(keyups.length).toBe(1); - expect((keyups[0] as { data: { estimated?: true } }).data.estimated).toBe( - true, - ); - }); - - it("uses average duration for estimated keyup timing", () => { - logTestEvent("timer", 1000, timerData("start", 0)); - // KeyA held for 80ms - logTestEvent("keydown", 1100, keyDown("KeyA")); - logTestEvent("keyup", 1180, keyUp("KeyA")); - // KeyS held for 120ms - logTestEvent("keydown", 1200, keyDown("KeyS")); - logTestEvent("keyup", 1320, keyUp("KeyS")); - // KeyD still held at 1400 - logTestEvent("keydown", 1400, keyDown("KeyD")); - - forceReleaseAllKeys(); - - const events = getAllTestEvents(); - const keyup = events.find( - (e) => e.type === "keyup" && e.data.code === "KeyD", - ); - // avg duration = (80+120)/2 = 100, so keyup at 1400+100 = 1500, testMs = 1500 - 1000 = 500 - expect(keyup).toBeDefined(); - expect(keyup?.testMs).toBe(500); - }); - - it("uses default 80ms when no completed key durations exist", () => { - logTestEvent("timer", 1000, timerData("start", 0)); - logTestEvent("keydown", 1200, keyDown("KeyA")); - - forceReleaseAllKeys(); - - const events = getAllTestEvents(); - const keyup = events.find( - (e) => e.type === "keyup" && e.data.code === "KeyA", - ); - expect(keyup).toBeDefined(); - expect(keyup?.testMs).toBe(280); - }); - - it("does nothing when no keys are pressed", () => { - logTestEvent("timer", 1000, timerData("start", 0)); - logTestEvent("keydown", 1100, keyDown("KeyA")); - logTestEvent("keyup", 1180, keyUp("KeyA")); - - // const beforeCount = getAllTestEvents().length; - forceReleaseAllKeys(); - // cache invalidated, re-get - resetTestEvents(); - // no new events should have been added — but we can't easily check after reset - // so instead verify no error is thrown - }); - }); -}); diff --git a/frontend/__tests__/test/events/helpers.spec.ts b/frontend/__tests__/test/events/helpers.spec.ts deleted file mode 100644 index e816ffe711..0000000000 --- a/frontend/__tests__/test/events/helpers.spec.ts +++ /dev/null @@ -1,385 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from "vite-plus/test"; - -const mockConfig = vi.hoisted(() => ({ funbox: "" })); -vi.mock("../../../src/ts/config/store", () => ({ - getMutableConfig: (() => { - const storedConfig = mockConfig; - return () => storedConfig; - })(), -})); - -import { - findInputValueMismatches, - getInputFromDom, -} from "@oxytype/typing-core/events/helpers"; -import { getTestEventCode } from "../../../src/features/typing/runtime/events/helpers"; -import type { InputEvent } from "@oxytype/typing-core/events/types"; -import type { InsertInputType } from "../../../src/ts/input/helpers/input-type"; - -let nextMs = 0; -let charIndex = 0; -let wordIndex = 0; -let currentInput = ""; - -function insert( - chars: string, - inputType: InsertInputType = "insertText", - overrides: { inputStopped?: true } = {}, -): InputEvent[] { - return [...chars].map((char) => { - nextMs += 10; - if (!overrides.inputStopped) { - currentInput += char; - } - const event: InputEvent = { - type: "input", - ms: nextMs, - testMs: nextMs, - data: { - charIndex, - wordIndex, - inputType, - data: char, - correct: true, - inputValue: currentInput, - ...overrides, - }, - }; - if (char !== " ") { - charIndex++; - } - if (char === " ") { - wordIndex++; - charIndex = 0; - } - return event; - }); -} - -function deleteBackward(count = 1): InputEvent[] { - return Array.from({ length: count }, () => { - nextMs += 10; - currentInput = currentInput.slice(0, -1); - const event: InputEvent = { - type: "input", - ms: nextMs, - testMs: nextMs, - data: { - charIndex, - wordIndex, - inputType: "deleteContentBackward", - inputValue: currentInput, - }, - }; - if (charIndex > 0) charIndex--; - return event; - }); -} - -function deleteWordBackward(): InputEvent { - nextMs += 10; - charIndex = 0; - currentInput = currentInput.replace(/(?:\S+\s*|\s+)$/, ""); - const event = { - type: "input", - ms: nextMs, - testMs: nextMs, - data: { - charIndex, - wordIndex, - inputType: "deleteWordBackward", - inputValue: currentInput, - }, - } as const; - if (wordIndex > 0) wordIndex--; - - return event; -} - -function reset(): void { - nextMs = 0; - charIndex = 0; - wordIndex = 0; - currentInput = ""; -} - -describe("getInputFromDom", () => { - beforeEach(() => { - reset(); - }); - - it("returns the last event's inputValue", () => { - expect(getInputFromDom([...insert("hello")])).toBe("hello"); - }); - - it("returns inputValue with trailing space", () => { - expect(getInputFromDom([...insert("hello ")])).toBe("hello "); - }); - - it("returns inputValue after deleteContentBackward", () => { - expect(getInputFromDom([...insert("abc"), ...deleteBackward()])).toBe("ab"); - }); - - it("returns inputValue after deleteContentBackward across space", () => { - expect(getInputFromDom([...insert("abc "), ...deleteBackward()])).toBe( - "abc", - ); - }); - - it("returns inputValue after multiple deletes", () => { - expect(getInputFromDom([...insert("ab"), ...deleteBackward(2)])).toBe(""); - }); - - it("returns inputValue after deleteWordBackward", () => { - expect(getInputFromDom([...insert("hello"), deleteWordBackward()])).toBe( - "", - ); - }); - - it("returns inputValue after deleteWordBackward across trailing space", () => { - expect(getInputFromDom([...insert("hello "), deleteWordBackward()])).toBe( - "", - ); - }); - - it("returns empty string for no events", () => { - expect(getInputFromDom([])).toBe(""); - }); - - it("returns empty after deleteContentBackward on empty string", () => { - expect(getInputFromDom([...deleteBackward()])).toBe(""); - }); - - it("inputStopped events keep prior inputValue", () => { - // inputStopped events should not advance currentInput in the helper, so - // the next character continues from "he" - expect( - getInputFromDom([ - ...insert("he"), - ...insert("x", "insertText", { inputStopped: true }), - ...insert("llo"), - ]), - ).toBe("hello"); - }); - - it("returns inputValue after deleteWordBackward mid-word", () => { - expect(getInputFromDom([...insert("a a"), deleteWordBackward()])).toBe( - "a ", - ); - }); - - it("returns inputValue after deleteWordBackward with multiple words", () => { - expect( - getInputFromDom([...insert("foo bar baz"), deleteWordBackward()]), - ).toBe("foo bar "); - }); - - it("returns inputValue after consecutive deleteWordBackward events", () => { - expect( - getInputFromDom([ - ...insert("foo bar baz"), - deleteWordBackward(), - deleteWordBackward(), - ]), - ).toBe("foo "); - }); - - it("trims trailing space when last event is incorrect last-word commit", () => { - const events: InputEvent[] = [ - ...insert("hi"), - { - type: "input", - ms: 100, - testMs: 100, - data: { - inputType: "insertText", - data: " ", - charIndex: 2, - wordIndex: 0, - correct: false, - inputValue: "hi ", - commitsWord: true, - lastWord: true, - }, - }, - ]; - expect(getInputFromDom(events)).toBe("hi"); - }); - - it("does not trim trailing space when commit is on non-last word", () => { - const events: InputEvent[] = [ - ...insert("hi"), - { - type: "input", - ms: 100, - testMs: 100, - data: { - inputType: "insertText", - data: " ", - charIndex: 2, - wordIndex: 0, - correct: false, - inputValue: "hi ", - commitsWord: true, - }, - }, - ]; - expect(getInputFromDom(events)).toBe("hi "); - }); -}); - -describe("findInputValueMismatches", () => { - beforeEach(() => { - reset(); - }); - - it("returns empty when no events have recorded inputValue", () => { - expect(findInputValueMismatches([...insert("hello")])).toEqual([]); - }); - - it("returns empty when recorded values match derivation", () => { - const events: InputEvent[] = [ - { - type: "input", - ms: 10, - testMs: 10, - data: { - inputType: "insertText", - data: "a", - charIndex: 0, - wordIndex: 0, - correct: true, - inputValue: "a", - }, - }, - { - type: "input", - ms: 20, - testMs: 20, - data: { - inputType: "insertText", - data: "b", - charIndex: 1, - wordIndex: 0, - correct: true, - inputValue: "ab", - }, - }, - ]; - expect(findInputValueMismatches(events)).toEqual([]); - }); - - it("returns mismatches when recorded value differs from derivation", () => { - const events: InputEvent[] = [ - { - type: "input", - ms: 10, - testMs: 10, - data: { - inputType: "insertText", - data: "a", - charIndex: 0, - wordIndex: 0, - correct: true, - inputValue: "DIFFERENT", - }, - }, - ]; - expect(findInputValueMismatches(events)).toEqual([ - { index: 0, derived: "a", recorded: "DIFFERENT" }, - ]); - }); - - it("skips events without inputValue, still tracks ones with it", () => { - const events: InputEvent[] = [ - ...insert("hello"), // no inputValue on these - { - type: "input", - ms: 100, - testMs: 100, - data: { - inputType: "deleteContentBackward", - charIndex: 5, - wordIndex: 0, - inputValue: "hell", - }, - }, - ]; - // derivation: "hello" then slice = "hell". Recorded = "hell". Match. - expect(findInputValueMismatches(events)).toEqual([]); - }); - - // it("handles insertCompositionText events", () => { - // const events = [ - // ...insert("k", "insertCompositionText"), - // ...insert("ka", "insertCompositionText"), - // ]; - // expect(getInputFromEvents(events)).toBe("ka"); - // }); - - // it("handles composition followed by regular text", () => { - // ...insert("ka", "insertCompositionText"), - // ...insert("b"), - // ]; - // expect(getInputFromEvents(events)).toBe("kab"); - // }); -}); - -function kbd(code: string, key?: string): KeyboardEvent { - return { code, key: key ?? "" } as KeyboardEvent; -} - -describe("getTestEventCode", () => { - beforeEach(() => { - mockConfig.funbox = ""; - }); - - it("returns the event code as-is for normal keys", () => { - expect(getTestEventCode(kbd("KeyA"))).toBe("KeyA"); - expect(getTestEventCode(kbd("Space"))).toBe("Space"); - expect(getTestEventCode(kbd("Digit1"))).toBe("Digit1"); - }); - - it("returns NoCode when code is empty string", () => { - expect(getTestEventCode(kbd(""))).toBe("NoCode"); - }); - - it("returns NoCode when key is Unidentified even with a valid code", () => { - expect(getTestEventCode(kbd("Semicolon", "Unidentified"))).toBe("NoCode"); - }); - - it("returns NoCode when key is Unidentified", () => { - expect(getTestEventCode(kbd("KeyA", "Unidentified"))).toBe("NoCode"); - }); - - it("returns Space for NumpadEnter when 58008 funbox is active", () => { - mockConfig.funbox = "58008"; - expect(getTestEventCode(kbd("NumpadEnter"))).toBe("Space"); - }); - - it("does not remap NumpadEnter without 58008 funbox", () => { - expect(getTestEventCode(kbd("NumpadEnter"))).toBe("NumpadEnter"); - }); - - it("returns NoCode for arrow keys when arrows funbox is active", () => { - mockConfig.funbox = "arrows"; - expect(getTestEventCode(kbd("ArrowUp"))).toBe("NoCode"); - expect(getTestEventCode(kbd("ArrowDown"))).toBe("NoCode"); - expect(getTestEventCode(kbd("ArrowLeft"))).toBe("NoCode"); - expect(getTestEventCode(kbd("ArrowRight"))).toBe("NoCode"); - }); - - it("does not remap arrow keys without arrows funbox", () => { - expect(getTestEventCode(kbd("ArrowUp"))).toBe("ArrowUp"); - }); - - it("handles 58008 funbox combined with other funboxes", () => { - mockConfig.funbox = "other#58008"; - expect(getTestEventCode(kbd("NumpadEnter"))).toBe("Space"); - }); - - it("handles arrows funbox combined with other funboxes", () => { - mockConfig.funbox = "arrows#other"; - expect(getTestEventCode(kbd("ArrowLeft"))).toBe("NoCode"); - }); -}); diff --git a/frontend/__tests__/test/events/stats.spec.ts b/frontend/__tests__/test/events/stats.spec.ts deleted file mode 100644 index c3100134d3..0000000000 --- a/frontend/__tests__/test/events/stats.spec.ts +++ /dev/null @@ -1,2101 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from "vite-plus/test"; - -vi.mock("../../../src/features/typing/runtime/test-stats", () => ({ - start: 1000, -})); - -const mockState = vi.hoisted(() => ({ activeWordIndex: 0 })); - -vi.mock("../../../src/ts/config/store", () => ({ - getMutableConfig: (() => { - const storedConfig = { - mode: "words", - funbox: [] as string[], - words: 25, - time: 0, - }; - return () => storedConfig; - })(), - getConfig: {}, -})); - -vi.mock("../../../src/features/typing/runtime/test-words", () => { - type CommitChar = " " | "\n" | ""; - type Word = { text: string; textWithCommit: string; commit: CommitChar }; - const list: Word[] = []; - return { - words: { - list, - get(): Word[] { - return [...list]; - }, - push(word: string) { - let commit: CommitChar = ""; - if (word.endsWith(" ")) { - commit = " "; - word = word.slice(0, -1); - } else if (word.endsWith("\n")) { - commit = "\n"; - word = word.slice(0, -1); - } - list.push({ text: word, textWithCommit: word + commit, commit }); - }, - reset() { - list.length = 0; - }, - }, - }; -}); - -const customTextLimit = { mode: "words" as "words" | "time", value: 0 }; -vi.mock("../../../src/features/typing/runtime/custom-text", () => ({ - getLimit: () => customTextLimit, -})); - -vi.mock("../../../src/ts/states/test", () => ({ - getCurrentQuote: () => null, - getActiveWordIndex: () => mockState.activeWordIndex, - isResultCalculating: () => false, - getBailedOut: () => false, - getKoreanStatus: () => false, -})); - -import { - logTestEvent, - resetTestEvents, - getAllTestEvents, - cleanupData, - buildEventLog, - getTestSession, -} from "../../../src/features/typing/runtime/events/data"; -import { - findInputValueMismatches, - getEventsForWord, - getEventsPerWord, -} from "@oxytype/typing-core/events/helpers"; -import { - getStartToFirstKeypressMs, - getLastKeypressToEndMs, - getBurstHistory, - getTestDurationMs, - getAccuracy, - getKeypressOverlap, - getErrorCountHistory, - getAfkDuration, - getIncompleteTestSeconds, - getKeypressDurations, - getKeypressesPerSecond, - getChars, - getInputHistory, - getWpmHistory, - __testing as statsTesting, - getCorrectedWordsHistory, - getKeypressSpacing, - getMissedWords, -} from "@oxytype/typing-core/events/stats"; -import type { - InputEventData, - InputEventNoMs, - KeydownEventData, - KeyupEventData, - TimerEventData, -} from "@oxytype/typing-core/events/types"; -import { getMutableConfig } from "../../../src/ts/config/store"; -import { Keycode } from "../../../src/ts/constants/keys"; -import { words as TestWords } from "../../../src/features/typing/runtime/test-words"; -import { isFunboxActiveWithProperty } from "../../../src/features/typing/runtime/funbox/list"; - -// mirror the generator: each word carries a trailing space separator unless it -// already ends with a newline, the nospace funbox is active, or it's the last -// word (the final separator is stripped once all words are generated) -function pushWords(...words: string[]): void { - const nospace = isFunboxActiveWithProperty("nospace"); - words.forEach((word, i) => { - const isLast = i === words.length - 1; - const withSeparator = - isLast || nospace || word.endsWith("\n") ? word : `${word} `; - TestWords.push(withSeparator, i); - }); -} - -function keyDown(code: Keycode = "KeyA"): KeydownEventData { - return { code }; -} - -function keyUp(code: Keycode = "KeyA"): KeyupEventData { - return { code }; -} - -const inputPerWord = new Map(); - -function input( - overrides: Partial<{ - charIndex: number; - wordIndex: number; - data: string; - correct: boolean; - inputType: string; - isCompositionEnding: boolean; - inputStopped: boolean; - commitsWord: true; - inputValue: string; - }> = {}, -): InputEventData { - const wordIndex = overrides.wordIndex ?? 0; - const data = overrides.data ?? "a"; - const inputStopped = overrides.inputStopped ?? false; - - let inputValue: string; - if (overrides.inputValue !== undefined) { - inputValue = overrides.inputValue; - } else { - const prev = inputPerWord.get(wordIndex) ?? ""; - inputValue = inputStopped ? prev : prev + data; - inputPerWord.set(wordIndex, inputValue); - } - - return { - charIndex: 0, - wordIndex: 0, - inputType: "insertText", - data: "a", - correct: true, - isCompositionEnding: false, - inputStopped: false, - inputValue, - ...overrides, - } as InputEventData; -} - -function timer( - event: "start" | "step" | "end", - timerVal: number, - opts: { catchup?: true } = {}, -): TimerEventData { - if (event === "step") { - return opts.catchup - ? { event, timer: timerVal, catchup: true } - : { event, timer: timerVal, drift: 0 }; - } - return { event, timer: timerVal, date: 0 }; -} - -// Helper: sets up a basic test with timer start, steps at 1s intervals, -// input events, and timer end -function setupBasicTest(): void { - // start=0, step@1s, step@2s, step@3s, end@3s - logTestEvent("timer", 1000, timer("start", 0)); - // 3 inputs in first second - logTestEvent("input", 1200, input()); - logTestEvent("input", 1400, input({ charIndex: 1 })); - logTestEvent("input", 1600, input({ charIndex: 2 })); - logTestEvent("timer", 2000, timer("step", 1)); - // 2 inputs in second second - logTestEvent("input", 2200, input({ charIndex: 3 })); - logTestEvent("input", 2400, input({ charIndex: 4 })); - logTestEvent("timer", 3000, timer("step", 2)); - // 1 input in third second - logTestEvent("input", 3200, input({ charIndex: 5 })); - logTestEvent("timer", 4000, timer("step", 3)); - logTestEvent("timer", 4000, timer("end", 3)); -} - -describe("stats.ts", () => { - beforeEach(() => { - resetTestEvents(); - getTestSession().recorder.__testing.resetPressedKeys(); - (getMutableConfig() as { mode: string }).mode = "words"; - (getMutableConfig() as { funbox: string[] }).funbox = []; - (getMutableConfig() as { words: number }).words = 25; - (getMutableConfig() as { time: number }).time = 0; - mockState.activeWordIndex = 0; - TestWords.reset(); - inputPerWord.clear(); - }); - - describe("getLaggedTimerBoundaries", () => { - it("returns step boundaries and end", () => { - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("timer", 2000, timer("step", 1)); - logTestEvent("timer", 3000, timer("step", 2)); - logTestEvent("timer", 4000, timer("step", 3)); - logTestEvent("timer", 4000, timer("end", 3)); - - const eventLog = buildEventLog(); - // end testMs=3000, last step testMs=3000 — gap is 0 < 500, end skipped - expect(statsTesting.getLaggedTimerBoundaries(eventLog)).toEqual([ - 1000, 2000, 3000, - ]); - }); - - it("includes end as boundary when endMs % 1000 >= 500ms", () => { - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("timer", 2000, timer("step", 1)); - logTestEvent("timer", 2500, timer("end", 1)); - - const eventLog = buildEventLog(); - // endMs=1500 → 1500%1000=500ms → roundTo2(0.5)=0.5 → boundary added - expect(statsTesting.getLaggedTimerBoundaries(eventLog)).toEqual([ - 1000, 1500, - ]); - }); - - it("skips end when too close to last step", () => { - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("timer", 2000, timer("step", 1)); - logTestEvent("timer", 2400, timer("end", 1)); - - const eventLog = buildEventLog(); - // end at testMs 1400, last step at testMs 1000 — gap is 400 < 500 - expect(statsTesting.getLaggedTimerBoundaries(eventLog)).toEqual([1000]); - }); - - it("includes end boundary when endMs % 1000 rounds to 0.5s", () => { - // endMs=1496 → 1496%1000=496ms → roundTo2(0.496)=0.50 → boundary added - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("timer", 2000, timer("step", 1)); - logTestEvent("timer", 2496, timer("end", 1)); - - const eventLog = buildEventLog(); - expect(statsTesting.getLaggedTimerBoundaries(eventLog)).toEqual([ - 1000, 1496, - ]); - }); - - it("skips end boundary when endMs % 1000 rounds below 0.5s", () => { - // endMs=1494 → 1494%1000=494ms → roundTo2(0.494)=0.49 → no boundary - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("timer", 2000, timer("step", 1)); - logTestEvent("timer", 2494, timer("end", 1)); - - const eventLog = buildEventLog(); - expect(statsTesting.getLaggedTimerBoundaries(eventLog)).toEqual([1000]); - }); - - it("skips end boundary for .49 test even when step fires slightly early (drift)", () => { - // Step fires 5ms early (at 995ms instead of 1000ms). - // Gap = 1490-995 = 495ms — the old gap-based check would have added a boundary - // (roundTo2(0.495)=0.5), but endMs%1000=490ms → roundTo2(0.49)<0.5 → no boundary. - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("timer", 1995, timer("step", 1)); - logTestEvent("timer", 2490, timer("end", 1)); - - const eventLog = buildEventLog(); - expect(statsTesting.getLaggedTimerBoundaries(eventLog)).toEqual([995]); - }); - - it("includes end boundary for .99 test even when step fires late (drift)", () => { - // Step fires 510ms late (at 1510ms instead of 1000ms). - // Gap = 1990-1510 = 480ms — gap-based check would miss the boundary, - // but endMs%1000=990ms → roundTo2(0.99)>=0.5 → boundary added. - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("timer", 2510, timer("step", 1)); - logTestEvent("timer", 2990, timer("end", 1)); - - const eventLog = buildEventLog(); - expect(statsTesting.getLaggedTimerBoundaries(eventLog)).toEqual([ - 1510, 1990, - ]); - }); - - it("excludes short trailing interval (<500ms) for non-round test duration", () => { - // 1.35s test: step at 1s, end at 1.35s — remainder 350ms < 500 - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("timer", 2000, timer("step", 1)); - logTestEvent("timer", 2350, timer("end", 1)); - - const eventLog = buildEventLog(); - // end testMs=1350, last step testMs=1000 — gap is 350 < 500, end skipped - expect(statsTesting.getLaggedTimerBoundaries(eventLog)).toEqual([1000]); - }); - - it("excludes short trailing interval (<500ms) for sub one second test duration", () => { - // 1.35s test: step at 1s, end at 1.35s — remainder 350ms < 500 - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("timer", 1350, timer("end", 0)); - - const eventLog = buildEventLog(); - // end testMs=1350, last step testMs=1000 — gap is 350 < 500, end skipped - expect(statsTesting.getLaggedTimerBoundaries(eventLog)).toEqual([]); - }); - - it("returns empty when no timer events", () => { - logTestEvent("keydown", 1000, keyDown()); - - const eventLog = buildEventLog(); - expect(statsTesting.getLaggedTimerBoundaries(eventLog)).toEqual([]); - }); - - it("adjusts end in zen mode by removing trailing afk", () => { - (getMutableConfig() as { mode: string }).mode = "zen"; - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("keydown", 1500, keyDown()); - logTestEvent("keyup", 1600, keyUp()); - logTestEvent("timer", 2000, timer("step", 1)); - logTestEvent("timer", 3000, timer("step", 2)); - // last keypress at testMs 500, end at testMs 4000 → lkte = 3500 - logTestEvent("timer", 5000, timer("end", 4)); - - const eventLog = buildEventLog(); - const boundaries = statsTesting.getLaggedTimerBoundaries(eventLog); - // adjusted end = 4000 - 3500 = 500, steps at 1000 and 2000 are past it - expect(boundaries).toEqual([500]); - }); - - it("skips end boundary when endMs rounds up to whole second", () => { - // endMs=19997: roundTo2(19.997)=20.00 → fractional 0 → no extra bucket - // Legacy CE1 doesn't push extra because stats.time=20.00 has no fractional. - // Naive (endMs % 1000)/1000 = 0.997 → roundTo2 = 1.0 would wrongly add. - logTestEvent("timer", 0, timer("start", 0)); - for (let i = 1; i <= 20; i++) { - logTestEvent("timer", i * 1000 - 10, timer("step", i)); - } - logTestEvent("timer", 19997, timer("end", 20)); - - const eventLog = buildEventLog(); - // 20 step boundaries, no end boundary (testSeconds rounds to 20.00) - expect(statsTesting.getLaggedTimerBoundaries(eventLog)).toHaveLength(20); - }); - - it("skips end boundary in time mode even when endMs %1000 >= 500ms", () => { - // 120s time test where timer fires step 120 slightly early at ~119.99s. - // Legacy time mode never pushes an extra bucket — CE2 must match by - // skipping the end boundary entirely. - (getMutableConfig() as { mode: string }).mode = "time"; - logTestEvent("timer", 0, timer("start", 0)); - for (let i = 1; i <= 120; i++) { - logTestEvent("timer", i * 1000 - 8, timer("step", i)); - } - logTestEvent("timer", 119994, timer("end", 120)); - - const eventLog = buildEventLog(); - const boundaries = statsTesting.getLaggedTimerBoundaries(eventLog); - // 120 step boundaries, no end boundary - expect(boundaries).toHaveLength(120); - }); - - it("skips end boundary in custom time mode", () => { - (getMutableConfig() as { mode: string }).mode = "custom"; - customTextLimit.mode = "time"; - try { - logTestEvent("timer", 0, timer("start", 0)); - for (let i = 1; i <= 30; i++) { - logTestEvent("timer", i * 1000, timer("step", i)); - } - logTestEvent("timer", 29994, timer("end", 30)); - - const eventLog = buildEventLog(); - expect(statsTesting.getLaggedTimerBoundaries(eventLog)).toHaveLength( - 30, - ); - } finally { - customTextLimit.mode = "words"; - } - }); - - describe("invariant: boundaries.length === Math.round(endMs / 1000)", () => { - // Sanity invariant: for non-timed modes, the number of timer boundaries - // must equal Math.round(testDurationSeconds) — matches the legacy - // keypressCountHistory length contract. Catches drift-induced extra steps - // or off-by-one boundaries. - const cases: { name: string; endMs: number }[] = [ - { name: ".00 (exactly 5s)", endMs: 5000 }, - { name: ".49 (5.49s)", endMs: 5490 }, - { name: ".50 (5.50s)", endMs: 5500 }, - { name: ".99 (5.99s)", endMs: 5990 }, - { name: "sub-1s .49", endMs: 490 }, - { name: "sub-1s .99", endMs: 990 }, - ]; - - for (const { name, endMs } of cases) { - it(`holds for ${name}`, () => { - logTestEvent("timer", 0, timer("start", 0)); - const fullSeconds = Math.floor(endMs / 1000); - for (let i = 1; i <= fullSeconds; i++) { - logTestEvent("timer", i * 1000, timer("step", i)); - } - logTestEvent("timer", endMs, timer("end", fullSeconds)); - - const eventLog = buildEventLog(); - const boundaries = statsTesting.getLaggedTimerBoundaries(eventLog); - const roundedDuration = Math.round(endMs / 1000); - expect(boundaries).toHaveLength(roundedDuration); - }); - } - }); - }); - - describe("getTimerBoundaries", () => { - it("returns empty when no end event", () => { - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("timer", 2000, timer("step", 1)); - - const eventLog = buildEventLog(); - expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([]); - }); - - it("returns ideal-grid boundaries based on end event's tick count", () => { - logTestEvent("timer", 1000, timer("start", 0)); - // step events with arbitrary drift — should not affect output - logTestEvent("timer", 1995, timer("step", 1)); - logTestEvent("timer", 3050, timer("step", 2)); - logTestEvent("timer", 3990, timer("step", 3)); - logTestEvent("timer", 4000, timer("end", 3)); - - const eventLog = buildEventLog(); - expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([ - 1000, 2000, 3000, - ]); - }); - - it("uses end event's timer field for tick count, ignoring step count", () => { - // simulates a stall: only one real step fired, but the test ended at tick 5 - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("timer", 2079, timer("step", 1)); - // catch-up + recovery would have logged more step events here - logTestEvent("timer", 6000, timer("end", 5)); - - const eventLog = buildEventLog(); - expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([ - 1000, 2000, 3000, 4000, 5000, - ]); - }); - - it("derives boundaries from wall-clock when a suspended tab froze the timer", () => { - // A backgrounded tab freezes the rAF-driven timer, so no step events fire - // and Time.get() stays 0 — but ~10s of real wall-clock elapsed. testMs - // (performance.now-based) still reflects it, so we get 10 boundaries. - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("keydown", 1050, keyDown()); - logTestEvent("keyup", 1150, keyUp()); - logTestEvent("timer", 11000, timer("end", 0)); - - const eventLog = buildEventLog(); - expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([ - 1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000, 10000, - ]); - }); - - it("appends fractional tail for non-timed test with .5s+ remainder", () => { - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("timer", 4500, timer("end", 3)); - - const eventLog = buildEventLog(); - // 3 ticks + tail at 3500ms - expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([ - 1000, 2000, 3000, 3500, - ]); - }); - - it("omits fractional tail under .5s for non-timed test", () => { - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("timer", 4250, timer("end", 3)); - - const eventLog = buildEventLog(); - expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([ - 1000, 2000, 3000, - ]); - }); - - it("omits fractional tail in time mode regardless of remainder", () => { - (getMutableConfig() as { mode: string }).mode = "time"; - logTestEvent("timer", 0, timer("start", 0)); - logTestEvent("timer", 15500, timer("end", 15)); - - const eventLog = buildEventLog(); - // 15 boundaries, no tail - expect(statsTesting.getTimerBoundaries(eventLog)).toHaveLength(15); - }); - - it("trims zen-mode trailing afk and caps tick count", () => { - (getMutableConfig() as { mode: string }).mode = "zen"; - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("keydown", 1500, keyDown()); - logTestEvent("keyup", 1600, keyUp()); - // last keypress at testMs 500, end at testMs 4000 → afk = 3500 - // adjusted endMs = 500 → 0 full ticks, plus tail (500ms >= .5s) - logTestEvent("timer", 5000, timer("end", 4)); - - const eventLog = buildEventLog(); - expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([500]); - }); - }); - - describe("getTimerBoundaryLabels", () => { - it("returns empty when no timer events", () => { - const eventLog = buildEventLog(); - expect(statsTesting.getTimerBoundaryLabels(eventLog)).toEqual([]); - }); - - it("labels clean step boundaries by index", () => { - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("timer", 2000, timer("step", 1)); - logTestEvent("timer", 3000, timer("step", 2)); - logTestEvent("timer", 4000, timer("step", 3)); - logTestEvent("timer", 4000, timer("end", 3)); - - const eventLog = buildEventLog(); - expect(statsTesting.getTimerBoundaryLabels(eventLog)).toEqual([ - "1", - "2", - "3", - ]); - }); - - it("labels a fractional trailing end boundary with its time", () => { - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("timer", 2000, timer("step", 1)); - logTestEvent("timer", 3000, timer("step", 2)); - logTestEvent("timer", 4000, timer("step", 3)); - logTestEvent("timer", 4500, timer("end", 3)); - - const eventLog = buildEventLog(); - expect(statsTesting.getTimerBoundaryLabels(eventLog)).toEqual([ - "1", - "2", - "3", - "3.50", - ]); - }); - - it("tolerates small step drift (within ~1 frame)", () => { - // steps fire ~5ms early due to drift — still label "1", "2", etc. - // end at a clean whole second so no tail boundary is added - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("timer", 1995, timer("step", 1)); - logTestEvent("timer", 2990, timer("step", 2)); - logTestEvent("timer", 3985, timer("step", 3)); - logTestEvent("timer", 4000, timer("end", 3)); - - const eventLog = buildEventLog(); - expect(statsTesting.getTimerBoundaryLabels(eventLog)).toEqual([ - "1", - "2", - "3", - ]); - }); - - it("labels the bucket containing a catchup recovery with LAG", () => { - // tick 2 (catchup) fires at testMs 3101 — falls in bucket (3000, 4000] - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("timer", 2079, timer("step", 1)); - logTestEvent("timer", 4101, timer("step", 2, { catchup: true })); - logTestEvent("timer", 4101, timer("step", 3)); - logTestEvent("timer", 5050, timer("step", 4)); - logTestEvent("timer", 5050, timer("end", 4)); - - const eventLog = buildEventLog(); - // perfect-grid boundaries: [1000, 2000, 3000, 4000] - // bucket 4 (boundary 4000, range (3000, 4000]) contains catchup at 3101 → LAG - expect(statsTesting.getTimerBoundaryLabels(eventLog)).toEqual([ - "1", - "2", - "3", - "LAG", - ]); - }); - }); - - describe("getStartToFirstKeypressMs", () => { - it("returns time from start to first keydown", () => { - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("keydown", 1150, keyDown()); - - expect(getStartToFirstKeypressMs(buildEventLog())).toBe(150); - }); - - it("returns 0 if keydown comes before start", () => { - logTestEvent("keydown", 900, keyDown()); - logTestEvent("timer", 1000, timer("start", 0)); - - expect(getStartToFirstKeypressMs(buildEventLog())).toBe(0); - }); - - it("returns 0 in zen mode", () => { - (getMutableConfig() as { mode: string }).mode = "zen"; - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("keydown", 1150, keyDown()); - - expect(getStartToFirstKeypressMs(buildEventLog())).toBe(0); - }); - - it("returns 0 if no events", () => { - expect(getStartToFirstKeypressMs(buildEventLog())).toBe(0); - }); - }); - - describe("getLastKeypressToEndMs", () => { - it("returns time from last keydown to end", () => { - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("keydown", 1500, keyDown()); - logTestEvent("keyup", 1600, keyUp()); - logTestEvent("keydown", 1800, keyDown()); - logTestEvent("timer", 2000, timer("end", 1)); - - expect(getLastKeypressToEndMs(buildEventLog())).toBe(200); - }); - - it("returns 0 in zen mode", () => { - (getMutableConfig() as { mode: string }).mode = "zen"; - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("keydown", 1500, keyDown()); - logTestEvent("timer", 2000, timer("end", 1)); - - expect(getLastKeypressToEndMs(buildEventLog())).toBe(0); - }); - }); - - describe("getTestDurationMs", () => { - it("returns end testMs", () => { - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("keydown", 1500, keyDown()); - logTestEvent("timer", 4000, timer("end", 3)); - - expect(getTestDurationMs(buildEventLog())).toBe(3000); - }); - - it("returns 0 if no end event", () => { - logTestEvent("timer", 1000, timer("start", 0)); - expect(getTestDurationMs(buildEventLog())).toBe(0); - }); - }); - - describe("getBurstHistory", () => { - it("converts keypresses to WPM using real interval duration", () => { - setupBasicTest(); - - const raw = getBurstHistory(buildEventLog()); - // 3 keypresses in 1s = (3/5)*60 = 36 WPM - expect(raw[0]).toBe(36); - // 2 keypresses in 1s = (2/5)*60 = 24 WPM - expect(raw[1]).toBe(24); - // 1 keypress in 1s = (1/5)*60 = 12 WPM - expect(raw[2]).toBe(12); - }); - - it("ignores non-insertText events", () => { - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("input", 1200, input()); - logTestEvent("input", 1400, { - charIndex: 1, - wordIndex: 0, - inputType: "deleteContentBackward", - } as InputEventData); - logTestEvent("timer", 2000, timer("step", 1)); - logTestEvent("timer", 2000, timer("end", 1)); - - const raw = getBurstHistory(buildEventLog()); - expect(raw).toEqual([12]); // 1 keypress in 1s - }); - }); - - describe("getErrorCountHistory", () => { - it("counts incorrect insertText events per interval", () => { - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("input", 1200, input({ correct: false })); - logTestEvent("input", 1400, input({ charIndex: 1 })); - logTestEvent("timer", 2000, timer("step", 1)); - logTestEvent("input", 2200, input({ charIndex: 2, correct: false })); - logTestEvent("input", 2400, input({ charIndex: 3, correct: false })); - logTestEvent("timer", 3000, timer("step", 2)); - logTestEvent("timer", 3000, timer("end", 2)); - - const errors = getErrorCountHistory(buildEventLog()); - expect(errors).toEqual([1, 2]); - }); - - it("returns zeros when all correct", () => { - setupBasicTest(); - const errors = getErrorCountHistory(buildEventLog()); - expect(errors).toEqual([0, 0, 0]); - }); - }); - - describe("getAfkDuration", () => { - it("counts intervals with no keydown events", () => { - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("keydown", 1200, keyDown()); - logTestEvent("keyup", 1300, keyUp()); - logTestEvent("timer", 2000, timer("step", 1)); - // no keydowns in second interval - logTestEvent("timer", 3000, timer("step", 2)); - logTestEvent("keydown", 3200, keyDown()); - logTestEvent("keyup", 3300, keyUp()); - logTestEvent("timer", 4000, timer("step", 3)); - logTestEvent("timer", 4000, timer("end", 3)); - - expect(getAfkDuration(buildEventLog())).toBe(1); - }); - - it("counts frozen-tab seconds as AFK when the end event's timer stalled at 0", () => { - // Regression: a suspended tab freezes the timer (Time.get() stuck at 0), - // so the end event reports timer:0 despite ~10s of real elapsed time. - // Deriving buckets from testMs means the idle seconds are still counted - // as AFK instead of collapsing to 0 (which leaked into typed-time/XP). - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("keydown", 1050, keyDown()); - logTestEvent("input", 1100, input()); - logTestEvent("keyup", 1150, keyUp()); - // frozen for the rest of the test; end reports the stalled timer:0 - logTestEvent("timer", 11000, timer("end", 0)); - - // 10 buckets, only the first has activity → 9 idle seconds - expect(getAfkDuration(buildEventLog())).toBe(9); - }); - - it("returns 0 when all intervals have keydowns", () => { - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("keydown", 1200, keyDown()); - logTestEvent("keyup", 1300, keyUp()); - logTestEvent("timer", 2000, timer("step", 1)); - logTestEvent("keydown", 2200, keyDown()); - logTestEvent("keyup", 2300, keyUp()); - logTestEvent("timer", 3000, timer("step", 2)); - logTestEvent("timer", 3000, timer("end", 2)); - - expect(getAfkDuration(buildEventLog())).toBe(0); - }); - }); - - describe("getIncompleteTestSeconds", () => { - // Guards the abandoned-test (restart) measurement: it must exclude idle - // time so a tab left open for hours doesn't leak into incompleteTestSeconds. - it("excludes idle time — only the typed span counts", () => { - // 60s elapsed, but typing only in the first 3 seconds, then idle - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("input", 1200, input()); // second 1 - logTestEvent("input", 2200, input({ charIndex: 1 })); // second 2 - logTestEvent("input", 3200, input({ charIndex: 2 })); // second 3 - logTestEvent("timer", 61000, timer("end", 60)); - - // duration 60s − 57 idle seconds = 3 - expect(getIncompleteTestSeconds(buildEventLog())).toBe(3); - }); - - it("keeps the full duration when typing is continuous", () => { - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("input", 1200, input()); // second 1 - logTestEvent("input", 2200, input({ charIndex: 1 })); // second 2 - logTestEvent("input", 3200, input({ charIndex: 2 })); // second 3 - logTestEvent("input", 4200, input({ charIndex: 3 })); // second 4 - logTestEvent("input", 5200, input({ charIndex: 4 })); // second 5 - logTestEvent("timer", 6000, timer("end", 5)); - - expect(getIncompleteTestSeconds(buildEventLog())).toBe(5); - }); - - it("returns 0 for an unterminated log (no timer end event)", () => { - // documents why the restart path must log a timer "end" first: with no - // boundaries getTestDurationMs is 0, so nothing leaks through - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("input", 1200, input()); - - expect(getIncompleteTestSeconds(buildEventLog())).toBe(0); - }); - - it("never goes negative", () => { - // pure-idle test: 0 typed seconds, all intervals AFK - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("timer", 4000, timer("end", 3)); - - expect(getIncompleteTestSeconds(buildEventLog())).toBe(0); - }); - }); - - describe("getInputHistory", () => { - it("treats abandoned word as empty when Firefox Ctrl+Backspace ate the sentinel", () => { - // Firefox groups whitespace + non-word punctuation as one delete run. - // Sequence: type "=ri" at word 1, Ctrl+Backspace twice. The first delete - // leaves "=" (browser deletes "ri" only). The second deletes the - // sentinel + "=" together, which oxytype interprets as crossing the - // word boundary → goToPreviousWord. Word 1 is abandoned with leftover - // "=" residue in its event stream; its final state should still be "". - pushWords("hello", "leave"); - - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent( - "input", - 1100, - input({ wordIndex: 0, data: "h", charIndex: 0 }), - ); - logTestEvent( - "input", - 1110, - input({ wordIndex: 0, data: "e", charIndex: 1 }), - ); - logTestEvent( - "input", - 1120, - input({ wordIndex: 0, data: "l", charIndex: 2 }), - ); - logTestEvent( - "input", - 1130, - input({ wordIndex: 0, data: "l", charIndex: 3 }), - ); - logTestEvent( - "input", - 1140, - input({ wordIndex: 0, data: "o", charIndex: 4 }), - ); - logTestEvent( - "input", - 1150, - input({ - wordIndex: 0, - data: " ", - charIndex: 5, - commitsWord: true, - }), - ); - - logTestEvent( - "input", - 1200, - input({ - wordIndex: 1, - data: "=", - correct: false, - charIndex: 0, - }), - ); - logTestEvent( - "input", - 1210, - input({ - wordIndex: 1, - data: "r", - correct: false, - charIndex: 1, - }), - ); - logTestEvent( - "input", - 1220, - input({ - wordIndex: 1, - data: "i", - correct: false, - charIndex: 2, - }), - ); - - // first Ctrl+Backspace: "=ri" → "=" - logTestEvent("input", 1300, { - wordIndex: 1, - charIndex: 3, - inputType: "deleteWordBackward", - inputValue: "=", - }); - - // second Ctrl+Backspace: Firefox ate sentinel + "=" → goToPreviousWord; - // clearedNextWord marks word 1 (= wordIndex + 1) as abandoned - logTestEvent("input", 1400, { - wordIndex: 0, - charIndex: 0, - inputType: "deleteWordBackward", - inputValue: "", - clearedNextWord: true, - }); - - logTestEvent("timer", 5000, timer("end", 4)); - - const history = getInputHistory(buildEventLog()); - expect(history[0]).toBe(""); - expect(history[1]).toBe(""); - }); - }); - - describe("getAccuracy", () => { - it("calculates correct/incorrect/percentage", () => { - logTestEvent("input", 1100, input()); - logTestEvent("input", 1200, input({ charIndex: 1 })); - logTestEvent("input", 1300, input({ charIndex: 2, correct: false })); - - const acc = getAccuracy(buildEventLog()); - expect(acc.correct).toBe(2); - expect(acc.incorrect).toBe(1); - expect(acc.percentage).toBeCloseTo(66.67, 1); - }); - - it("returns 0% for no events", () => { - const acc = getAccuracy(buildEventLog()); - expect(acc.percentage).toBe(0); - }); - - it("ignores delete events", () => { - logTestEvent("input", 1100, input()); - logTestEvent("input", 1200, { - charIndex: 0, - wordIndex: 0, - inputType: "deleteContentBackward", - } as InputEventData); - - const acc = getAccuracy(buildEventLog()); - expect(acc.correct).toBe(1); - expect(acc.incorrect).toBe(0); - }); - - it("counts inputStopped events in accuracy", () => { - logTestEvent("input", 1100, input()); - logTestEvent( - "input", - 1200, - input({ charIndex: 1, correct: false, inputStopped: true }), - ); - - const acc = getAccuracy(buildEventLog()); - expect(acc.correct).toBe(1); - expect(acc.incorrect).toBe(1); - expect(acc.percentage).toBe(50); - }); - }); - - describe("getKeypressSpacing", () => { - it("returns spacing between consecutive keydowns", () => { - logTestEvent("keydown", 1000, keyDown()); - logTestEvent("keyup", 1050, keyUp()); - logTestEvent("keydown", 1100, keyDown()); - logTestEvent("keyup", 1150, keyUp()); - logTestEvent("keydown", 1250, keyDown()); - logTestEvent("keyup", 1300, keyUp()); - - const spacings = getKeypressSpacing(buildEventLog()); - expect(spacings).toEqual([100, 150]); - }); - - it("returns empty for single keydown", () => { - logTestEvent("keydown", 1000, keyDown()); - - expect(getKeypressSpacing(buildEventLog())).toEqual([]); - }); - - it("clamps a pre-start first keydown so the timing invariant holds", () => { - // A keydown can be recorded before timer:start (e.g. a stray Ctrl+H - // pressed seconds before the user starts typing). cleanupData keeps the - // last pre-start keydown by design, and getStartToFirstKeypressMs clamps - // its negative offset to 0 — so the first spacing must clamp the same - // way, else sum(keySpacing) inflates by |firstKeydown| and breaks - // the testDuration vs key timings check. - (getMutableConfig() as { mode: string }).mode = "time"; - logTestEvent("keydown", -16240, keyDown()); - logTestEvent("timer", 0, timer("start", 0)); - logTestEvent("input", 0, input()); - logTestEvent("keyup", 100, keyUp()); - logTestEvent("keydown", 500, keyDown("KeyS")); - logTestEvent("keyup", 580, keyUp("KeyS")); - logTestEvent("timer", 1000, timer("step", 1)); - logTestEvent("timer", 1000, timer("end", 1)); - - const sumSpacing = getKeypressSpacing(buildEventLog()).reduce( - (a, b) => a + b, - 0, - ); - const total = - getStartToFirstKeypressMs(buildEventLog()) + - sumSpacing + - getLastKeypressToEndMs(buildEventLog()); - - expect(Math.abs(getTestDurationMs(buildEventLog()) - total)).toBeLessThan( - 100, - ); - }); - - it("cleanupData drops post-end keydowns so the timing invariant holds", () => { - // The compareCompletedEvents check in test-logic.ts relies on: - // startToFirstKey + sum(keySpacing) + lastKeyToEnd ≈ testDuration - // In time mode the user often keeps typing during finish()'s fade - // animation. Without filtering, post-end keydowns inflate keySpacing - // while getLastKeypressToEndMs clamps the negative diff to 0, breaking - // the invariant. finish() calls cleanupData() before the check — - // that's the centralised filter; this test exercises the same path. - (getMutableConfig() as { mode: string }).mode = "time"; - logTestEvent("timer", 0, timer("start", 0)); - logTestEvent("keydown", 500, keyDown()); - logTestEvent("keyup", 580, keyUp()); - logTestEvent("keydown", 700, keyDown()); - logTestEvent("keyup", 780, keyUp()); - logTestEvent("timer", 1000, timer("step", 1)); - logTestEvent("timer", 1000, timer("end", 1)); - // user keeps typing through the fade - logTestEvent("keydown", 1120, keyDown()); - logTestEvent("keyup", 1170, keyUp()); - - cleanupData(); - - const sumSpacing = getKeypressSpacing(buildEventLog()).reduce( - (a, b) => a + b, - 0, - ); - const total = - getStartToFirstKeypressMs(buildEventLog()) + - sumSpacing + - getLastKeypressToEndMs(buildEventLog()); - - expect(Math.abs(getTestDurationMs(buildEventLog()) - total)).toBeLessThan( - 100, - ); - }); - }); - - describe("getKeypressOverlap", () => { - it("measures time when multiple keys are held", () => { - logTestEvent("keydown", 1000, keyDown("KeyA")); - logTestEvent("keydown", 1050, keyDown("KeyS")); - // both held from 1050-1080 = 30ms overlap - logTestEvent("keyup", 1080, keyUp("KeyA")); - logTestEvent("keyup", 1100, keyUp("KeyS")); - - expect(getKeypressOverlap(buildEventLog())).toBe(30); - }); - - it("returns 0 with no overlap", () => { - logTestEvent("keydown", 1000, keyDown("KeyA")); - logTestEvent("keyup", 1050, keyUp("KeyA")); - logTestEvent("keydown", 1100, keyDown("KeyS")); - logTestEvent("keyup", 1150, keyUp("KeyS")); - - expect(getKeypressOverlap(buildEventLog())).toBe(0); - }); - }); - - describe("getKeypressDurations", () => { - it("measures hold duration for each key", () => { - logTestEvent("keydown", 1000, keyDown("KeyA")); - logTestEvent("keyup", 1080, keyUp("KeyA")); - logTestEvent("keydown", 1100, keyDown("KeyS")); - logTestEvent("keyup", 1200, keyUp("KeyS")); - - const durations = getKeypressDurations(buildEventLog()); - expect(durations).toEqual([80, 100]); - }); - - it("returns 0 for keys without keyup", () => { - logTestEvent("keydown", 1000, keyDown()); - - const durations = getKeypressDurations(buildEventLog()); - expect(durations).toEqual([0]); - }); - }); - - describe("getKeypressesPerSecond", () => { - it("counts insertText events per timer interval", () => { - setupBasicTest(); - - const kps = getKeypressesPerSecond(buildEventLog()); - expect(kps).toEqual([3, 2, 1]); - }); - - it("ignores delete events", () => { - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("input", 1200, input()); - logTestEvent("input", 1400, { - charIndex: 1, - wordIndex: 0, - inputType: "deleteContentBackward", - } as InputEventData); - logTestEvent("timer", 2000, timer("step", 1)); - logTestEvent("timer", 2000, timer("end", 1)); - - expect(getKeypressesPerSecond(buildEventLog())).toEqual([1]); - }); - - it("returns empty for no timer events", () => { - logTestEvent("input", 1200, input()); - expect(getKeypressesPerSecond(buildEventLog())).toEqual([]); - }); - - it("counts keypresses in last partial second when gap rounds to 0.5s", () => { - // mirrors the totalKeypressCountHistory mismatch: legacy pushes for roundTo2 >= 0.5, - // but the old boundary check (>= 500ms) skips a 496ms tail - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("input", 1200, input()); // first second - logTestEvent("timer", 2000, timer("step", 1)); - logTestEvent("input", 2200, input({ charIndex: 1 })); // 496ms tail - logTestEvent("input", 2400, input({ charIndex: 2 })); - logTestEvent("timer", 2496, timer("end", 1)); - - // endMs=1496, 1496%1000=496ms → roundTo2(0.496)=0.5 → end boundary added → [1, 2] - expect(getKeypressesPerSecond(buildEventLog())).toEqual([1, 2]); - }); - }); - - describe("getTargetWord", () => { - it("returns word", () => { - pushWords("hello"); - expect(statsTesting.getTargetWord(buildEventLog(), 0)).toBe("hello"); - }); - it("returns for out-of-range", () => { - expect(statsTesting.getTargetWord(buildEventLog(), 0)).toBe(undefined); - }); - }); - - describe("getMissedWords", () => { - it("strips the commit separator but keeps a trailing tab", () => { - // word 0 is a code-mode-style word ending in a tab; pushWords appends the - // " " separator, so targetWords[0] is "foo\t " — the key must be "foo\t" - pushWords("foo\t", "bar"); - - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent( - "input", - 1100, - input({ wordIndex: 0, data: "x", correct: false, charIndex: 0 }), - ); - - expect(getMissedWords(buildEventLog())).toEqual({ "foo\t": 1 }); - }); - - it("strips a trailing space separator", () => { - pushWords("hello", "world"); - - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent( - "input", - 1100, - input({ wordIndex: 0, data: "x", correct: false, charIndex: 0 }), - ); - - expect(getMissedWords(buildEventLog())).toEqual({ hello: 1 }); - }); - }); - - describe("getChars", () => { - it("counts all correct for a perfectly typed word", () => { - pushWords("hello"); - mockState.activeWordIndex = 0; - - logTestEvent("timer", 1000, timer("start", 0)); - for (let i = 0; i < 5; i++) { - logTestEvent( - "input", - 1100 + i * 50, - input({ charIndex: i, wordIndex: 0, data: "hello"[i] as string }), - ); - } - - const chars = getChars(buildEventLog()); - expect(chars.allCorrect).toBe(5); - expect(chars.correctWord).toBe(5); - expect(chars.incorrect).toBe(0); - expect(chars.extra).toBe(0); - expect(chars.missed).toBe(0); - }); - - it("counts incorrect chars", () => { - pushWords("ab"); - mockState.activeWordIndex = 0; - - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent( - "input", - 1100, - input({ charIndex: 0, wordIndex: 0, data: "a" }), - ); - logTestEvent( - "input", - 1150, - input({ charIndex: 1, wordIndex: 0, data: "x", correct: false }), - ); - - const chars = getChars(buildEventLog()); - expect(chars.allCorrect).toBe(1); - expect(chars.incorrect).toBe(1); - }); - - it("counts extra chars", () => { - pushWords("ab"); - mockState.activeWordIndex = 0; - - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent( - "input", - 1100, - input({ charIndex: 0, wordIndex: 0, data: "a" }), - ); - logTestEvent( - "input", - 1150, - input({ charIndex: 1, wordIndex: 0, data: "b" }), - ); - logTestEvent( - "input", - 1200, - input({ charIndex: 2, wordIndex: 0, data: "c" }), - ); - - const chars = getChars(buildEventLog()); - expect(chars.extra).toBe(1); - }); - - it("counts missed chars for completed non-last words", () => { - pushWords("hello", "world"); - mockState.activeWordIndex = 1; - - logTestEvent("timer", 1000, timer("start", 0)); - // type "hel" then space (incomplete first word) - logTestEvent( - "input", - 1100, - input({ charIndex: 0, wordIndex: 0, data: "h" }), - ); - logTestEvent( - "input", - 1150, - input({ charIndex: 1, wordIndex: 0, data: "e" }), - ); - logTestEvent( - "input", - 1200, - input({ charIndex: 2, wordIndex: 0, data: "l" }), - ); - logTestEvent( - "input", - 1250, - input({ - charIndex: 3, - wordIndex: 0, - data: " ", - commitsWord: true, - }), - ); - // type "w" on second word - logTestEvent( - "input", - 1300, - input({ charIndex: 0, wordIndex: 1, data: "w" }), - ); - - const chars = getChars(buildEventLog()); - // word 0: "hel " vs "hello " → 3 correct, 1 incorrect, 2 missed - // word 1: "w" vs "world" → 1 correct, 4 missed (words mode counts partial last word missed) - expect(chars.missed).toBe(6); - }); - - it("credits a word committed with an IME full-width space", () => { - // Japanese IME commits words with the ideographic space U+3000, while the - // target word separator is a regular space — normalize so it still counts - pushWords("しり", "かこ"); - mockState.activeWordIndex = 1; - - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent( - "input", - 1100, - input({ charIndex: 0, wordIndex: 0, data: "し" }), - ); - logTestEvent( - "input", - 1150, - input({ charIndex: 1, wordIndex: 0, data: "り" }), - ); - logTestEvent( - "input", - 1200, - input({ - charIndex: 2, - wordIndex: 0, - data: " ", - commitsWord: true, - }), - ); - logTestEvent( - "input", - 1300, - input({ charIndex: 0, wordIndex: 1, data: "か" }), - ); - - const chars = getChars(buildEventLog()); - // word 0 "しり " is fully correct (2 chars + separator) - expect(chars.correctWord).toBe(3); - expect(chars.incorrect).toBe(0); - expect(chars.extra).toBe(0); - }); - }); - - describe("getWpmHistory", () => { - it("returns wpm at each timer boundary", () => { - pushWords("hello"); - mockState.activeWordIndex = 0; - - logTestEvent("timer", 1000, timer("start", 0)); - // type "hello" in first second — 5 correct word chars - for (let i = 0; i < 5; i++) { - logTestEvent( - "input", - 1100 + i * 50, - input({ charIndex: i, wordIndex: 0, data: "hello"[i] as string }), - ); - } - logTestEvent("timer", 2000, timer("step", 1)); - logTestEvent("timer", 2000, timer("end", 1)); - - const wpm = getWpmHistory(buildEventLog()); - // 5 correct chars in 1s = (5/5)*60 = 60 WPM - expect(wpm).toEqual([60]); - }); - - it("returns cumulative wpm across boundaries", () => { - pushWords("ab", "cd"); - mockState.activeWordIndex = 1; - - logTestEvent("timer", 1000, timer("start", 0)); - // type "ab " in first second — correct word - logTestEvent( - "input", - 1100, - input({ charIndex: 0, wordIndex: 0, data: "a" }), - ); - logTestEvent( - "input", - 1200, - input({ charIndex: 1, wordIndex: 0, data: "b" }), - ); - logTestEvent( - "input", - 1300, - input({ charIndex: 2, wordIndex: 0, data: " " }), - ); - logTestEvent("timer", 2000, timer("step", 1)); - // type "cd" in second second - logTestEvent( - "input", - 2100, - input({ charIndex: 0, wordIndex: 1, data: "c" }), - ); - logTestEvent( - "input", - 2200, - input({ charIndex: 1, wordIndex: 1, data: "d" }), - ); - logTestEvent("timer", 3000, timer("step", 2)); - logTestEvent("timer", 3000, timer("end", 2)); - - const wpm = getWpmHistory(buildEventLog()); - expect(wpm.length).toBe(2); - // at 1s: "ab " fully correct = 3 correctWord chars → (3/5)*60 = 36 - expect(wpm[0]).toBe(36); - // at 2s: 3 + 2 ("cd") = 5 correctWord chars → (5/5)*60/2 = 30 - expect(wpm[1]).toBe(30); - }); - - it("counts non-last word as correct without trailing space when nospace funbox is active", () => { - (getMutableConfig() as { funbox: string[] }).funbox = ["nospace"]; - pushWords("ab", "cd"); - mockState.activeWordIndex = 1; - - logTestEvent("timer", 1000, timer("start", 0)); - // type "ab" then "cd" with no space between (nospace mode) - logTestEvent( - "input", - 1100, - input({ charIndex: 0, wordIndex: 0, data: "a" }), - ); - logTestEvent( - "input", - 1200, - input({ charIndex: 1, wordIndex: 0, data: "b" }), - ); - logTestEvent( - "input", - 1300, - input({ charIndex: 0, wordIndex: 1, data: "c" }), - ); - logTestEvent( - "input", - 1400, - input({ charIndex: 1, wordIndex: 1, data: "d" }), - ); - logTestEvent("timer", 2000, timer("step", 1)); - logTestEvent("timer", 2000, timer("end", 1)); - - const wpm = getWpmHistory(buildEventLog()); - // both words fully correct → 4 correctWord chars in 1s = (4/5)*60 = 48 - expect(wpm).toEqual([48]); - }); - - it("counts multiline word as correct when target ends in newline", () => { - pushWords("hello\n", "world"); - mockState.activeWordIndex = 1; - - logTestEvent("timer", 1000, timer("start", 0)); - // type "hello\n" - const word1 = "hello\n"; - for (let i = 0; i < word1.length; i++) { - logTestEvent( - "input", - 1100 + i * 50, - input({ charIndex: i, wordIndex: 0, data: word1[i] as string }), - ); - } - // type "world" - const word2 = "world"; - for (let i = 0; i < word2.length; i++) { - logTestEvent( - "input", - 1500 + i * 50, - input({ charIndex: i, wordIndex: 1, data: word2[i] as string }), - ); - } - logTestEvent("timer", 2000, timer("step", 1)); - logTestEvent("timer", 2000, timer("end", 1)); - - const wpm = getWpmHistory(buildEventLog()); - // word 0: "hello\n" target matches input "hello\n" → 6 correctWord - // word 1: "world" (last word) matches → 5 correctWord - // 11 chars in 1s = (11/5)*60 = 132 - expect(wpm).toEqual([132]); - }); - }); - - describe("inferActiveWordIndex", () => { - it("returns 0 when no word has input", () => { - const eventsPerWord = new Map(); - expect(statsTesting.inferActiveWordIndex(eventsPerWord)).toBe(0); - }); - - it("returns 0 when entries exist but none have input", () => { - // word events present but all input data is empty / inputValue="" - logTestEvent( - "input", - 1000, - input({ wordIndex: 0, data: "", inputValue: "" }), - ); - const eventsPerWord = getEventsPerWord(getAllTestEvents()); - expect(statsTesting.inferActiveWordIndex(eventsPerWord)).toBe(0); - }); - - it("returns max wordIndex when last word has no committed space", () => { - // word 0: "hi" - logTestEvent("input", 1000, input({ wordIndex: 0, data: "h" })); - logTestEvent( - "input", - 1050, - input({ wordIndex: 0, charIndex: 1, data: "i" }), - ); - // space commit on word 0 - logTestEvent( - "input", - 1100, - input({ - wordIndex: 0, - charIndex: 2, - data: " ", - commitsWord: true, - inputValue: "hi ", - }), - ); - // word 1: "yo" (no trailing space) - logTestEvent("input", 1200, input({ wordIndex: 1, data: "y" })); - logTestEvent( - "input", - 1250, - input({ wordIndex: 1, charIndex: 1, data: "o" }), - ); - - const eventsPerWord = getEventsPerWord(getAllTestEvents()); - expect(statsTesting.inferActiveWordIndex(eventsPerWord)).toBe(1); - }); - - it("advances past last word when trailing space was committed", () => { - // word 0: "hi " - logTestEvent("input", 1000, input({ wordIndex: 0, data: "h" })); - logTestEvent( - "input", - 1050, - input({ wordIndex: 0, charIndex: 1, data: "i" }), - ); - logTestEvent( - "input", - 1100, - input({ - wordIndex: 0, - charIndex: 2, - data: " ", - commitsWord: true, - inputValue: "hi ", - }), - ); - - const eventsPerWord = getEventsPerWord(getAllTestEvents()); - expect(statsTesting.inferActiveWordIndex(eventsPerWord)).toBe(1); - }); - - it("does not advance when last event is a non-space insert", () => { - logTestEvent("input", 1000, input({ wordIndex: 0, data: "h" })); - logTestEvent( - "input", - 1050, - input({ wordIndex: 0, charIndex: 1, data: "i" }), - ); - - const eventsPerWord = getEventsPerWord(getAllTestEvents()); - expect(statsTesting.inferActiveWordIndex(eventsPerWord)).toBe(0); - }); - - it("does not advance when last event is a backspace", () => { - logTestEvent("input", 1000, input({ wordIndex: 0, data: "h" })); - logTestEvent( - "input", - 1050, - input({ wordIndex: 0, charIndex: 1, data: "i" }), - ); - logTestEvent( - "input", - 1100, - input({ - wordIndex: 0, - charIndex: 1, - inputType: "deleteContentBackward", - data: "", - inputValue: "h", - }), - ); - - const eventsPerWord = getEventsPerWord(getAllTestEvents()); - expect(statsTesting.inferActiveWordIndex(eventsPerWord)).toBe(0); - }); - - it("picks max wordIndex across non-contiguous buckets (post-regression order)", () => { - // simulates a backspace that crosses back into word 0 AFTER word 1 events. - // Map insertion order is still 0, 1 (word 0 was set first), so the loop - // must compute true max by key, not by iteration position. - logTestEvent("input", 1000, input({ wordIndex: 0, data: "h" })); - logTestEvent( - "input", - 1050, - input({ - wordIndex: 0, - charIndex: 1, - data: " ", - commitsWord: true, - inputValue: "h ", - }), - ); - logTestEvent("input", 1100, input({ wordIndex: 1, data: "y" })); - // backspace lands a destination event back into word 0 - logTestEvent( - "input", - 1200, - input({ - wordIndex: 0, - charIndex: 1, - inputType: "deleteContentBackward", - data: "", - inputValue: "h", - }), - ); - - const eventsPerWord = getEventsPerWord(getAllTestEvents()); - // word 1 has input "y" (no trailing space) → max is 1, no advance - expect(statsTesting.inferActiveWordIndex(eventsPerWord)).toBe(1); - }); - }); - - describe("getCorrectedWords", () => { - it("returns input as-is when no corrections made", () => { - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent( - "input", - 1100, - input({ charIndex: 0, wordIndex: 0, data: "t" }), - ); - logTestEvent( - "input", - 1150, - input({ charIndex: 1, wordIndex: 0, data: "e" }), - ); - logTestEvent( - "input", - 1200, - input({ charIndex: 2, wordIndex: 0, data: "s" }), - ); - logTestEvent( - "input", - 1250, - input({ charIndex: 3, wordIndex: 0, data: "t" }), - ); - - expect(getCorrectedWordsHistory(buildEventLog())).toEqual(["test"]); - }); - - it("returns last deleted char per position (xact -> fact)", () => { - logTestEvent("timer", 1000, timer("start", 0)); - // type "xact" - logTestEvent( - "input", - 1100, - input({ charIndex: 0, wordIndex: 0, data: "x" }), - ); - logTestEvent( - "input", - 1150, - input({ charIndex: 1, wordIndex: 0, data: "a" }), - ); - logTestEvent( - "input", - 1200, - input({ charIndex: 2, wordIndex: 0, data: "c" }), - ); - logTestEvent( - "input", - 1250, - input({ charIndex: 3, wordIndex: 0, data: "t" }), - ); - // delete all - logTestEvent("input", 1300, { - charIndex: 3, - wordIndex: 0, - inputType: "deleteContentBackward", - } as InputEventData); - logTestEvent("input", 1350, { - charIndex: 2, - wordIndex: 0, - inputType: "deleteContentBackward", - } as InputEventData); - logTestEvent("input", 1400, { - charIndex: 1, - wordIndex: 0, - inputType: "deleteContentBackward", - } as InputEventData); - logTestEvent("input", 1450, { - charIndex: 0, - wordIndex: 0, - inputType: "deleteContentBackward", - } as InputEventData); - // type "fact" - logTestEvent( - "input", - 1500, - input({ charIndex: 0, wordIndex: 0, data: "f" }), - ); - logTestEvent( - "input", - 1550, - input({ charIndex: 1, wordIndex: 0, data: "a" }), - ); - logTestEvent( - "input", - 1600, - input({ charIndex: 2, wordIndex: 0, data: "c" }), - ); - logTestEvent( - "input", - 1650, - input({ charIndex: 3, wordIndex: 0, data: "t" }), - ); - - expect(getCorrectedWordsHistory(buildEventLog())).toEqual(["xact"]); - }); - - it("returns last deleted char per position across multiple corrections (xest -> west -> test)", () => { - logTestEvent("timer", 1000, timer("start", 0)); - // type "xest" - logTestEvent( - "input", - 1100, - input({ charIndex: 0, wordIndex: 0, data: "x" }), - ); - logTestEvent( - "input", - 1150, - input({ charIndex: 1, wordIndex: 0, data: "e" }), - ); - logTestEvent( - "input", - 1200, - input({ charIndex: 2, wordIndex: 0, data: "s" }), - ); - logTestEvent( - "input", - 1250, - input({ charIndex: 3, wordIndex: 0, data: "t" }), - ); - // delete all - logTestEvent("input", 1300, { - charIndex: 3, - wordIndex: 0, - inputType: "deleteWordBackward", - } as InputEventData); - // type "west" - logTestEvent( - "input", - 1400, - input({ charIndex: 0, wordIndex: 0, data: "w" }), - ); - logTestEvent( - "input", - 1450, - input({ charIndex: 1, wordIndex: 0, data: "e" }), - ); - logTestEvent( - "input", - 1500, - input({ charIndex: 2, wordIndex: 0, data: "s" }), - ); - logTestEvent( - "input", - 1550, - input({ charIndex: 3, wordIndex: 0, data: "t" }), - ); - // delete all - logTestEvent("input", 1600, { - charIndex: 3, - wordIndex: 0, - inputType: "deleteWordBackward", - } as InputEventData); - // type "test" - logTestEvent( - "input", - 1700, - input({ charIndex: 0, wordIndex: 0, data: "t" }), - ); - logTestEvent( - "input", - 1750, - input({ charIndex: 1, wordIndex: 0, data: "e" }), - ); - logTestEvent( - "input", - 1800, - input({ charIndex: 2, wordIndex: 0, data: "s" }), - ); - logTestEvent( - "input", - 1850, - input({ charIndex: 3, wordIndex: 0, data: "t" }), - ); - - expect(getCorrectedWordsHistory(buildEventLog())).toEqual(["west"]); - }); - - it("handles partial correction (tset -> delete last 2 -> st)", () => { - logTestEvent("timer", 1000, timer("start", 0)); - // type "tset" - logTestEvent( - "input", - 1100, - input({ charIndex: 0, wordIndex: 0, data: "t" }), - ); - logTestEvent( - "input", - 1150, - input({ charIndex: 1, wordIndex: 0, data: "s" }), - ); - logTestEvent( - "input", - 1200, - input({ charIndex: 2, wordIndex: 0, data: "e" }), - ); - logTestEvent( - "input", - 1250, - input({ charIndex: 3, wordIndex: 0, data: "t" }), - ); - // delete last 2 - logTestEvent("input", 1300, { - charIndex: 3, - wordIndex: 0, - inputType: "deleteContentBackward", - } as InputEventData); - logTestEvent("input", 1350, { - charIndex: 2, - wordIndex: 0, - inputType: "deleteContentBackward", - } as InputEventData); - // type "st" - logTestEvent( - "input", - 1400, - input({ charIndex: 2, wordIndex: 0, data: "s" }), - ); - logTestEvent( - "input", - 1450, - input({ charIndex: 3, wordIndex: 0, data: "t" }), - ); - - // pos 0: "t" never deleted, pos 1: "s" never deleted, pos 2: "e" deleted, pos 3: "t" deleted - expect(getCorrectedWordsHistory(buildEventLog())).toEqual(["tset"]); - }); - - it("handles multiple words", () => { - logTestEvent("timer", 1000, timer("start", 0)); - // word 0: type "ab" correctly - logTestEvent( - "input", - 1100, - input({ charIndex: 0, wordIndex: 0, data: "a" }), - ); - logTestEvent( - "input", - 1150, - input({ charIndex: 1, wordIndex: 0, data: "b" }), - ); - // word 1: type "xy", delete both, type "zw" - logTestEvent( - "input", - 1200, - input({ charIndex: 0, wordIndex: 1, data: "x" }), - ); - logTestEvent( - "input", - 1250, - input({ charIndex: 1, wordIndex: 1, data: "y" }), - ); - logTestEvent("input", 1300, { - charIndex: 1, - wordIndex: 1, - inputType: "deleteContentBackward", - } as InputEventData); - logTestEvent("input", 1350, { - charIndex: 1, - wordIndex: 1, - inputType: "deleteContentBackward", - } as InputEventData); - logTestEvent( - "input", - 1400, - input({ charIndex: 0, wordIndex: 1, data: "z" }), - ); - logTestEvent( - "input", - 1450, - input({ charIndex: 1, wordIndex: 1, data: "w" }), - ); - - const result = getCorrectedWordsHistory(buildEventLog()); - expect(result[0]).toEqual("ab"); - expect(result[1]).toEqual("xy"); - }); - - it("keeps the space that commits a word", () => { - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent( - "input", - 1100, - input({ charIndex: 0, wordIndex: 0, data: "t" }), - ); - logTestEvent( - "input", - 1150, - input({ charIndex: 1, wordIndex: 0, data: "e" }), - ); - logTestEvent( - "input", - 1200, - input({ charIndex: 2, wordIndex: 0, data: "s" }), - ); - logTestEvent( - "input", - 1250, - input({ charIndex: 3, wordIndex: 0, data: "t" }), - ); - // committing space — kept as the trailing separator of the corrected word - logTestEvent( - "input", - 1300, - input({ - charIndex: 4, - wordIndex: 0, - data: " ", - commitsWord: true, - }), - ); - - expect(getCorrectedWordsHistory(buildEventLog())).toEqual(["test "]); - }); - }); - - // the deleteOnError config deletes input from within the insertText handler; - // these mirror the event sequences it emits (see input/handlers/insert-text) - describe("delete on error", () => { - function inputEventsForWord(wordIndex: number): InputEventNoMs[] { - return getEventsForWord(getAllTestEvents(), wordIndex).filter( - (e): e is InputEventNoMs => e.type === "input", - ); - } - - it("letter mode deletes the incorrect char and the one before it", () => { - pushWords("hello", "world"); - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("input", 1100, input({ wordIndex: 0, data: "h" })); - logTestEvent( - "input", - 1110, - input({ wordIndex: 0, data: "e", charIndex: 1 }), - ); - logTestEvent( - "input", - 1120, - input({ wordIndex: 0, data: "x", charIndex: 2, correct: false }), - ); - logTestEvent("input", 1120, { - wordIndex: 0, - charIndex: 3, - inputType: "deleteContentBackward", - automatic: true, - inputValue: "he", - }); - logTestEvent("input", 1120, { - wordIndex: 0, - charIndex: 2, - inputType: "deleteContentBackward", - automatic: true, - inputValue: "h", - }); - logTestEvent("timer", 5000, timer("end", 4)); - - expect(findInputValueMismatches(inputEventsForWord(0))).toEqual([]); - expect(getInputHistory(buildEventLog())[0]).toBe("h"); - // the mistake still counts against accuracy - const acc = getAccuracy(buildEventLog()); - expect(acc.correct).toBe(2); - expect(acc.incorrect).toBe(1); - // and is still visible in the corrected history - expect(getCorrectedWordsHistory(buildEventLog())[0]).toBe("hex"); - }); - - it("letter mode deletes only the incorrect char at the start of a word", () => { - pushWords("hello", "world"); - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent( - "input", - 1100, - input({ wordIndex: 0, data: "x", correct: false }), - ); - logTestEvent("input", 1100, { - wordIndex: 0, - charIndex: 1, - inputType: "deleteContentBackward", - automatic: true, - inputValue: "", - }); - logTestEvent("timer", 5000, timer("end", 4)); - - expect(findInputValueMismatches(inputEventsForWord(0))).toEqual([]); - expect(getInputHistory(buildEventLog())[0]).toBe(""); - }); - - it("word mode clears the whole word", () => { - pushWords("hello", "world"); - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("input", 1100, input({ wordIndex: 0, data: "h" })); - logTestEvent( - "input", - 1110, - input({ wordIndex: 0, data: "e", charIndex: 1 }), - ); - logTestEvent( - "input", - 1120, - input({ wordIndex: 0, data: "x", charIndex: 2, correct: false }), - ); - logTestEvent("input", 1120, { - wordIndex: 0, - charIndex: 3, - inputType: "deleteWordBackward", - automatic: true, - inputValue: "", - }); - logTestEvent("timer", 5000, timer("end", 4)); - - expect(findInputValueMismatches(inputEventsForWord(0))).toEqual([]); - expect(getInputHistory(buildEventLog())[0]).toBe(""); - expect(getCorrectedWordsHistory(buildEventLog())[0]).toBe("hex"); - }); - - it("hard mode regresses to the previous word on a first-char mistake", () => { - pushWords("hello", "world"); - logTestEvent("timer", 1000, timer("start", 0)); - for (const [i, char] of [..."hello"].entries()) { - logTestEvent( - "input", - 1100 + i * 10, - input({ wordIndex: 0, data: char, charIndex: i }), - ); - } - logTestEvent( - "input", - 1150, - input({ wordIndex: 0, data: " ", charIndex: 5, commitsWord: true }), - ); - - mockState.activeWordIndex = 1; - logTestEvent( - "input", - 1200, - input({ wordIndex: 1, data: "x", correct: false }), - ); - // the incorrect char is deleted from the word it was typed in... - logTestEvent("input", 1200, { - wordIndex: 1, - charIndex: 1, - inputType: "deleteContentBackward", - automatic: true, - inputValue: "", - }); - // ...then the regression lands on the previous word, separator removed - mockState.activeWordIndex = 0; - logTestEvent("input", 1200, { - wordIndex: 0, - charIndex: 5, - inputType: "deleteContentBackward", - automatic: true, - inputValue: "hello", - }); - logTestEvent("timer", 5000, timer("end", 4)); - - expect(findInputValueMismatches(inputEventsForWord(0))).toEqual([]); - expect(findInputValueMismatches(inputEventsForWord(1))).toEqual([]); - const history = getInputHistory(buildEventLog()); - expect(history[0]).toBe("hello"); - expect(history[1]).toBe(""); - }); - - it("marks its deletions as automatic, the user's own are not", () => { - pushWords("hello", "world"); - logTestEvent("timer", 1000, timer("start", 0)); - logTestEvent("input", 1100, input({ wordIndex: 0, data: "h" })); - logTestEvent( - "input", - 1110, - input({ wordIndex: 0, data: "x", charIndex: 1, correct: false }), - ); - // the two deletions delete-on-error triggered - logTestEvent("input", 1110, { - wordIndex: 0, - charIndex: 2, - inputType: "deleteContentBackward", - inputValue: "h", - automatic: true, - }); - logTestEvent("input", 1110, { - wordIndex: 0, - charIndex: 1, - inputType: "deleteContentBackward", - inputValue: "", - automatic: true, - }); - // one the user pressed themselves afterwards - logTestEvent("input", 1300, { - wordIndex: 0, - charIndex: 0, - inputType: "deleteContentBackward", - inputValue: "", - }); - logTestEvent("timer", 5000, timer("end", 4)); - - const deletes = inputEventsForWord(0).filter((e) => - e.data.inputType.startsWith("delete"), - ); - expect(deletes.map((e) => e.data.automatic)).toEqual([ - true, - true, - undefined, - ]); - // the flag is informational - it does not change what is derived - expect(getInputHistory(buildEventLog())[0]).toBe(""); - }); - }); -}); diff --git a/frontend/__tests__/test/funbox.spec.ts b/frontend/__tests__/test/funbox.spec.ts index b075fa962d..4ed3254806 100644 --- a/frontend/__tests__/test/funbox.spec.ts +++ b/frontend/__tests__/test/funbox.spec.ts @@ -3,9 +3,21 @@ import { getAllFunboxes } from "../../src/features/typing/runtime/funbox/list"; describe("funbox", () => { describe("list", () => { - it("should have every frontendFunctions function defined", () => { + it("accounts for every hook in its native or browser owner", () => { + // Shared word rules were removed from browser hooks. Native generation, + // source, transform and metadata parity suites exercise these six hooks. + const native = new Set([ + "getWord", + "punctuateWord", + "withWords", + "alterText", + "pullSection", + "getWordsFrequencyMode", + ]); for (const funbox of getAllFunboxes()) { - const packageFunctions = (funbox.frontendFunctions ?? []).sort(); + const packageFunctions = (funbox.frontendFunctions ?? []) + .filter((name) => !native.has(name)) + .sort(); const implementations = Object.keys(funbox.functions ?? {}).sort(); let message = "has mismatched functions"; diff --git a/frontend/__tests__/test/funbox/funbox-validation.spec.ts b/frontend/__tests__/test/funbox/funbox-validation.spec.ts index c69202f0c1..96ae3f4add 100644 --- a/frontend/__tests__/test/funbox/funbox-validation.spec.ts +++ b/frontend/__tests__/test/funbox/funbox-validation.spec.ts @@ -1,4 +1,6 @@ -import { describe, it, expect } from "vite-plus/test"; +import "../../__harness__/native-engine"; +import { evaluatePolicy } from "@oxytype/typing-engine/browser.client"; +import { describe, it, expect, vi } from "vite-plus/test"; import { canSetConfigWithCurrentFunboxes } from "../../../src/ts/config/funbox-validation"; import { FunboxName } from "@oxytype/schemas/configs"; @@ -72,3 +74,10 @@ describe("funbox-validation", () => { ); }); }); + +vi.mock("../../../src/features/typing/engine.client", () => ({ + getBrowserEngine: () => ({ + status: () => ({ state: "ready" }), + api: () => ({ policy: evaluatePolicy }), + }), +})); diff --git a/frontend/__tests__/test/result.spec.ts b/frontend/__tests__/test/result.spec.ts index f357894043..31d86a1889 100644 --- a/frontend/__tests__/test/result.spec.ts +++ b/frontend/__tests__/test/result.spec.ts @@ -1,3 +1,5 @@ +import "../__harness__/native-engine"; +import { evaluatePolicy } from "@oxytype/typing-engine/browser.client"; import { describe, it, expect, beforeEach, vi } from "vite-plus/test"; // Baseline for what test/result.ts#update shows on the result screen (stat @@ -86,7 +88,7 @@ vi.mock("../../src/ts/states/test", async (importOriginal) => ({ getKoreanStatus: () => false, isTestInvalid: () => state.testInvalid, })); -vi.mock("@oxytype/typing-core/events/stats", () => ({ +vi.mock("../../src/features/typing/analysis.client", () => ({ getAccuracy: () => state.accuracy, getRawHistory: () => [], getTimerBoundaryLabels: () => [], @@ -654,3 +656,10 @@ describe("result update", () => { }); }); }); + +vi.mock("../../src/features/typing/engine.client", () => ({ + getBrowserEngine: () => ({ + status: () => ({ state: "ready" }), + api: () => ({ policy: evaluatePolicy }), + }), +})); diff --git a/frontend/__tests__/test/test-logic.spec.ts b/frontend/__tests__/test/test-logic.spec.ts index 801e8042ed..545a67af13 100644 --- a/frontend/__tests__/test/test-logic.spec.ts +++ b/frontend/__tests__/test/test-logic.spec.ts @@ -3,7 +3,7 @@ import { animateAsync } from "../../src/ts/anim"; import { requireApplication } from "../../src/app/application"; import { bindTypingActivity } from "../../src/features/typing/scope"; import { createRoot, onCleanup } from "solid-js"; -import type { QuoteWithTextSplit } from "@oxytype/typing-core/quotes"; +import type { QuoteWithTextSplit } from "@oxytype/web-shared/quotes"; import type { LanguageObject } from "@oxytype/schemas/languages"; import { afterEach, @@ -30,6 +30,8 @@ const mocks = vi.hoisted(() => ({ allGenerated: true, pullSection: undefined as undefined | (() => Promise), sourceActions: [] as unknown[], + engineLoad: vi.fn(async () => undefined), + restartPolicy: vi.fn(), generate: vi.fn(), complete: vi.fn(), sessionStart: vi.fn(), @@ -51,6 +53,13 @@ const mocks = vi.hoisted(() => ({ onFinish: undefined as ((event: { reason?: string }) => void) | undefined, })); +vi.mock("../../src/features/typing/runtime/content.client", () => ({ + getContentHost: () => ({ prepare: mocks.generate }), +})); +vi.mock("../../src/features/typing/engine.client", () => ({ + getBrowserEngine: () => ({ load: mocks.engineLoad }), +})); + vi.mock("../../src/ts/states/test", () => ({ isTestActive: () => mocks.active, setTestActive: (value: boolean) => { @@ -119,6 +128,8 @@ vi.mock("../../src/features/typing/runtime/test-ui", () => ({ vi.mock("../../src/features/typing/runtime/events/data", () => ({ getTestSession: (() => { const session = { + isPrepared: () => true, + restartPolicy: mocks.restartPolicy, start: mocks.sessionStart, generate: mocks.generate, complete: mocks.complete, @@ -147,9 +158,6 @@ vi.mock("../../src/features/typing/runtime/events/data", () => ({ context: { targetWords: mocks.generatedWords }, }), })); -vi.mock("../../src/features/typing/runtime/words-generator", () => ({ - areAllWordsGenerated: () => mocks.allGenerated, -})); vi.mock("../../src/features/typing/runtime/funbox/list", () => ({ getActiveFunboxes: () => [], getActiveFunboxesWithFunction: () => [], @@ -255,15 +263,14 @@ vi.mock("../../src/ts/utils/result-hash", () => ({ hashResult: vi.fn().mockResolvedValue("hash"), preloadResultHasher: vi.fn().mockResolvedValue(undefined), })); -vi.mock("@oxytype/typing-core/result-validity", () => ({ - getInvalidResultReason: () => undefined, - isAfkResult: () => false, +vi.mock("../../src/features/typing/analysis.client", () => ({ + analyze: () => ({ reason: null, afk: false }), + getAnalysis: () => ({ incompleteTestSeconds: 0, dateDurationMs: 0 }), })); import { getMutableConfig } from "../../src/ts/config/store"; import { getDefaultConfig } from "../../src/ts/constants/default-config"; import { - addWord, fail, finish, restart, @@ -275,6 +282,17 @@ import { let disposeRuntime: () => void; afterEach(() => disposeRuntime()); beforeEach(() => { + mocks.engineLoad.mockReset().mockResolvedValue(undefined); + mocks.restartPolicy + .mockReset() + .mockImplementation((options: { withSameWordset?: boolean }) => ({ + allowed: true, + reason: null, + withSameWordset: options.withSameWordset ?? false, + recordIncomplete: true, + preventDefault: false, + repeatWithPace: false, + })); vi.clearAllMocks(); disposeRuntime = createRoot((dispose) => { onCleanup(bindTypingActivity(() => !mocks.transition)); @@ -363,6 +381,16 @@ describe("test lifecycle state", () => { "rejects restarts while %s", async (guard) => { mocks[guard] = true; + if (guard === "noQuit") { + mocks.restartPolicy.mockReturnValueOnce({ + allowed: false, + reason: "noQuit", + preventDefault: true, + withSameWordset: false, + recordIncomplete: false, + repeatWithPace: false, + }); + } mocks.active = true; const event = { preventDefault: vi.fn() } as unknown as KeyboardEvent; await restart({ event }); @@ -371,6 +399,21 @@ describe("test lifecycle state", () => { }, ); + it("keeps failed Wasm loading out of the typing loop and retries on restart", async () => { + mocks.engineLoad.mockRejectedValueOnce( + new Error("Typing engine download failed: 503"), + ); + await restart(); + expect(mocks.setInitError).toHaveBeenCalledWith({ + message: "Typing engine download failed: 503", + }); + expect(mocks.generate).not.toHaveBeenCalled(); + expect(mocks.restarting).toBe(false); + await restart(); + expect(mocks.generate).toHaveBeenCalledOnce(); + expect(mocks.uiRestart).toHaveBeenCalledWith("testPage"); + }); + it("caps initialization retries and starts a fresh retry budget on restart", async () => { const error = new Error("generation failed"); mocks.generate.mockRejectedValue(error); @@ -464,6 +507,14 @@ describe("shared test decisions", () => { getMutableConfig().mode = "quote"; getMutableConfig().repeatQuotes = repeatQuotes; mocks.quote = { language: "english", id: 1 } as QuoteWithTextSplit; + mocks.restartPolicy.mockReturnValueOnce({ + allowed: true, + reason: null, + withSameWordset: repeatQuotes === "typing", + repeatWithPace: repeatQuotes === "typing", + recordIncomplete: true, + preventDefault: false, + }); mocks.active = true; await restart(); expect(mocks.repeated).toBe(repeatQuotes === "typing"); @@ -531,41 +582,7 @@ it("never applies a pending save acknowledgement to another actor", async () => mockHttpFixture("results.add", async (input) => await mocks.addResult(input)); -// Frozen behavior before the Rust host cutover. Source I/O failure is recovery, not a score failure. -describe("section source recovery", () => { - it("reports, disables the source and restarts on an unavailable active section", async () => { - mocks.allGenerated = false; - mocks.pullSection = async () => false; - mocks.error.mockImplementation((message: string) => - mocks.sourceActions.push({ op: "errorNotice", message }), - ); - mocks.generate.mockImplementation(async () => { - mocks.sourceActions.push({ op: "restart" }); - return { - words: mocks.generatedWords, - sectionIndexes: [0, 0], - hasTab: false, - hasNewline: false, - }; - }); - await addWord(); - await vi.waitFor(() => expect(mocks.restarting).toBe(false)); - expect(mocks.sourceActions).toEqual([ - { - op: "errorNotice", - message: "Error while getting section. Please try again later", - }, - { op: "disableFunbox", name: "poetry", noRestart: false }, - { op: "restart" }, - ]); - expect(mocks.complete).not.toHaveBeenCalled(); - }); - it("ignores an undefined active section without reporting a failure", async () => { - mocks.allGenerated = false; - mocks.pullSection = async () => undefined; - await addWord(); - expect(mocks.sourceActions).toEqual([]); - expect(mocks.generate).not.toHaveBeenCalled(); - expect(mocks.error).not.toHaveBeenCalled(); - }); -}); +// Active source failure ordering is frozen in source-recovery.json and verified +// natively (content.rs), through Wasm (engine-proof.migration.ts), and by the +// owned browser content/projection tests. The removed addWord callback no longer +// owns source fetching or completion. diff --git a/frontend/__tests__/test/timer-scheduler.spec.ts b/frontend/__tests__/test/timer-scheduler.spec.ts index 2b81145111..4574ed7c1a 100644 --- a/frontend/__tests__/test/timer-scheduler.spec.ts +++ b/frontend/__tests__/test/timer-scheduler.spec.ts @@ -1,4 +1,3 @@ -import { createTestTimer, type TimerTick } from "@oxytype/typing-core/timer"; import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; import { createTimerScheduler } from "../../src/features/typing/runtime/timer-scheduler"; beforeEach(() => @@ -6,43 +5,50 @@ beforeEach(() => ); afterEach(() => vi.useRealTimers()); -it("keeps one clock across restarts and stops synchronously when a test finishes", () => { - const ticks: number[] = []; - const clock = createTestTimer({ - onTick: ({ timer }) => { - ticks.push(timer); - if (timer === 2) scheduler.stop(); - }, - }); +it("replaces scheduled callbacks and stops synchronously when native clock ends", () => { + const clock = { + start: vi.fn(), + stop: vi.fn(), + advance: vi.fn((_now: number): number | null => 1000), + }; const scheduler = createTimerScheduler(clock); + clock.advance + .mockImplementationOnce(() => 1000) + .mockImplementationOnce(() => { + scheduler.stop(); + return null; + }); scheduler.start(performance.now()); vi.advanceTimersByTime(500); scheduler.start(performance.now()); vi.advanceTimersByTime(500); - expect(ticks).toEqual([]); + expect(clock.advance).not.toHaveBeenCalled(); vi.advanceTimersByTime(1500); - expect(ticks).toEqual([1, 2]); - expect(clock.isRunning()).toBe(false); + expect(clock.advance).toHaveBeenCalledTimes(2); expect(vi.getTimerCount()).toBe(0); vi.advanceTimersByTime(5000); - expect(ticks).toEqual([1, 2]); + expect(clock.advance).toHaveBeenCalledTimes(2); }); -it("catches up a delayed callback and schedules the next anchored second", () => { - const tick = vi.fn<(value: TimerTick) => void>(); - const clock = createTestTimer({ onTick: tick }); +it("schedules the next callback using the exact delay supplied by the native clock", () => { + const clock = { + start: vi.fn(), + stop: vi.fn(), + advance: vi.fn((_now: number): number | null => null), + }; + clock.advance.mockReturnValueOnce(750); const scheduler = createTimerScheduler(clock); const now = vi.spyOn(performance, "now").mockReturnValue(0); scheduler.start(0); now.mockReturnValue(3250); vi.advanceTimersByTime(1000); - expect(tick.mock.calls.map(([value]) => value.timer)).toEqual([1, 2, 3]); - expect(tick.mock.calls[0]?.[0]).toMatchObject({ catchup: true }); + expect(clock.advance).toHaveBeenCalledWith(3250); now.mockReturnValue(4000); vi.advanceTimersByTime(749); - expect(tick).toHaveBeenCalledTimes(3); + expect(clock.advance).toHaveBeenCalledTimes(1); vi.advanceTimersByTime(1); - expect(tick.mock.calls[3]?.[0]).toMatchObject({ timer: 4, drift: 0 }); + expect(clock.advance).toHaveBeenLastCalledWith(4000); + expect(vi.getTimerCount()).toBe(0); scheduler.stop(); now.mockRestore(); }); diff --git a/frontend/__tests__/test/wikipedia.spec.ts b/frontend/__tests__/test/wikipedia.spec.ts deleted file mode 100644 index e9c597a6d5..0000000000 --- a/frontend/__tests__/test/wikipedia.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -vi.mock("../../src/ts/utils/json-data", () => ({ - getLanguage: vi.fn(), - Section: class { - title: string; - author: string; - words: string[]; - constructor(title: string, author: string, words: string[]) { - this.title = title; - this.author = author; - this.words = words; - } - }, -})); -vi.mock("../../src/ts/states/loader-bar", () => ({ - showLoaderBar: vi.fn(), - hideLoaderBar: vi.fn(), -})); - -import { getLanguage } from "../../src/ts/utils/json-data"; -import { getSection } from "../../src/features/typing/runtime/wikipedia"; - -afterEach(() => { - vi.unstubAllGlobals(); - vi.restoreAllMocks(); -}); - -describe("Wikipedia extract parsing", () => { - it.each([ - [ - "en", - "

‘Fish’ & chips[12].

Next\u200b — café.

", - ["'Fish'", "&", "chips.", "Next", "-", "caf."], - ], - [ - "fr", - "

Été & hiver.

Après [1] demain.

", - ["Été", "&", "hiver.", "Après", "demain."], - ], - ])("preserves extracted text for %s", async (bcp47, extract, words) => { - vi.mocked(getLanguage).mockResolvedValue({ bcp47 } as Awaited< - ReturnType - >); - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - status: 200, - json: async () => ({ - title: "Article", - author: "Author", - pageid: 12, - }), - }), - ); - vi.stubGlobal( - "XMLHttpRequest", - class { - readyState = 4; - status = 200; - responseText = JSON.stringify({ - query: { pages: { "12": { extract } } }, - }); - onload?: () => void; - open(): void { - /* Network transport is stubbed. */ - } - send(): void { - this.onload?.(); - } - }, - ); - expect(await getSection("english")).toEqual({ - title: "Article", - author: "Author", - words, - }); - }); -}); diff --git a/frontend/__tests__/test/words-generator-parity.spec.ts b/frontend/__tests__/test/words-generator-parity.spec.ts index 3e28fcf63f..1dab20707b 100644 --- a/frontend/__tests__/test/words-generator-parity.spec.ts +++ b/frontend/__tests__/test/words-generator-parity.spec.ts @@ -1,180 +1,124 @@ -import { describe, it, expect, vi, beforeEach } from "vite-plus/test"; -import { readFileSync } from "fs"; -import { resolve } from "path"; -import { fileURLToPath } from "url"; - -// Snapshots of the words generator for the typing-core extraction. Every case -// (packages/typing-core/__fixtures__/words/cases.json) runs with a seeded -// Math.random, so typing-core can reproduce the exact same words. -// Expected output is committed; missing fixtures must fail the test. - -const state = vi.hoisted(() => { - // mulberry32 - const seeded = (seed: number): (() => number) => { - let a = seed; - return () => { - a |= 0; - a = (a + 0x6d2b79f5) | 0; - let t = Math.imul(a ^ (a >>> 15), 1 | a); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; - }; - const s = { - seeded, - rng: seeded(0), - repeated: false, - currentQuote: null as unknown, - selectedQuoteId: 1, - customText: { - text: [] as string[], - mode: "repeat", - limit: { value: 0, mode: "word" }, - pipeDelimiter: false, - }, - }; - // modules pin Math.random when they load, so it has to be swapped first - Math.random = () => s.rng(); - return s; -}); - -const STATIC_DIR = resolve(fileURLToPath(import.meta.url), "../../../static"); - -function readStatic(path: string): unknown { - return JSON.parse( - readFileSync(resolve(STATIC_DIR, path.replace(/^\//, "")), "utf-8"), - ) as unknown; -} - -vi.mock("../../src/ts/config/store", async () => { - const { getDefaultConfig } = - await import("../../src/ts/constants/default-config"); - return { - getMutableConfig: (() => { - const storedConfig = getDefaultConfig(); - return () => storedConfig; - })(), - getConfig: {}, - }; -}); -vi.mock("../../src/ts/config/setters", () => ({ - setConfig: vi.fn(), - setQuoteLengthAll: vi.fn(), - toggleFunbox: vi.fn(), -})); -vi.mock("../../src/ts/states/test", () => ({ - isRepeated: () => state.repeated, - getCurrentQuote: () => state.currentQuote, - setCurrentQuote: (quote: unknown) => { - state.currentQuote = quote; - }, - getSelectedQuoteId: () => state.selectedQuoteId, - getActiveWordIndex: () => 0, -})); -vi.mock("../../src/ts/states/loader-bar", () => ({ - showLoaderBar: vi.fn(), - hideLoaderBar: vi.fn(), -})); -vi.mock("../../src/features/typing/runtime/practise-words", () => ({ - before: { mode: null }, -})); -vi.mock("../../src/features/typing/runtime/custom-text", () => ({ - getText: () => state.customText.text, - getMode: () => state.customText.mode, - getLimit: () => state.customText.limit, - getLimitMode: () => state.customText.limit.mode, - getLimitValue: () => state.customText.limit.value, - getPipeDelimiter: () => state.customText.pipeDelimiter, -})); -vi.mock("../../src/ts/utils/json-data", async (importOriginal) => ({ - ...(await importOriginal()), - // fresh objects every time - word order funboxes reverse the list in place - getLanguage: async (lang: string) => readStatic(`languages/${lang}.json`), - cachedFetchJson: async (url: string) => readStatic(url), -})); - -import { getMutableConfig } from "../../src/ts/config/store"; +import { afterAll, describe, expect, it } from "vite-plus/test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import "../__harness__/native-engine"; +import { BrowserContent } from "@oxytype/typing-engine/browser.client"; +import type { Prepare } from "@oxytype/typing-engine/browser.client"; +import { projectEngineConfig } from "@oxytype/typing-engine/config"; +import { encodeText, decodeText } from "@oxytype/typing-engine/text"; +import { + engineLanguage, + engineQuotes, +} from "../../src/features/typing/runtime/content-assets.client"; +import type { Config } from "@oxytype/schemas/configs"; +import type { CustomTextSettings } from "@oxytype/schemas/results"; +import type { LanguageObject } from "@oxytype/schemas/languages"; +import type { QuoteData } from "@oxytype/schemas/quotes"; import { getDefaultConfig } from "../../src/ts/constants/default-config"; -import * as WordsGenerator from "../../src/features/typing/runtime/words-generator"; -import * as TestWords from "../../src/features/typing/runtime/test-words"; -import * as JSONData from "../../src/ts/utils/json-data"; -import { Config as ConfigType } from "@oxytype/schemas/configs"; -const WORDS_DIR = resolve( - fileURLToPath(import.meta.url), - "../../../../packages/typing-core/__fixtures__/words", +// The original snapshots are independent, immutable references. Test the actual +// web asset conversion and optimized Wasm, without retaining the old web engine. +const root = resolve( + import.meta.dirname, + "../../../packages/typing-fixtures/reference/words", ); - -type GeneratorCase = { +const staticRoot = resolve(import.meta.dirname, "../../static"); +function read(path: string): T { + return JSON.parse(readFileSync(path, "utf8")) as T; +} +type Case = { name: string; - phase: string; seed: number; - config: Partial; - customText?: typeof state.customText; + config: Partial; + customText?: CustomTextSettings; repeat?: boolean; extraWords?: number; selectedQuoteId?: number; }; - -const cases = JSON.parse( - readFileSync(resolve(WORDS_DIR, "cases.json"), "utf-8"), -) as GeneratorCase[]; - -async function run(c: GeneratorCase): Promise { - Object.assign(getMutableConfig(), getDefaultConfig(), c.config); - state.rng = state.seeded(c.seed); - state.repeated = false; - state.currentQuote = null; - state.selectedQuoteId = c.selectedQuoteId ?? 1; - if (c.customText !== undefined) { - state.customText = structuredClone(c.customText); - } - - const language = await JSONData.getLanguage(getMutableConfig().language); - - let gen = await WordsGenerator.generateWords(language); - if (c.repeat === true) { - state.repeated = true; - gen = await WordsGenerator.generateWords( - await JSONData.getLanguage(getMutableConfig().language), +const cases = read(resolve(root, "cases.json")); +const content = new BrowserContent(); +let quotesLoaded = false; +afterAll(() => content.dispose()); +function run(fixture: Case): unknown { + const config = projectEngineConfig({ + ...getDefaultConfig(), + ...fixture.config, + }); + let quotes: Prepare["quotes"] = null; + if (config.mode === "quote" && !quotesLoaded) { + quotes = engineQuotes( + read(resolve(staticRoot, "quotes/english.json")), ); + quotesLoaded = true; } - - TestWords.words.reset(); - gen.words.forEach((word, i) => { - TestWords.words.push(word, gen.sectionIndexes[i] as number); + content.prepare({ + config, + language: engineLanguage( + read( + resolve(staticRoot, `languages/${config.language}.json`), + ), + ), + custom: + fixture.customText === undefined + ? null + : { + ...fixture.customText, + text: fixture.customText.text.map(encodeText), + }, + random: { kind: "seeded", state: fixture.seed }, + quotes, + polyglot: config.funbox.includes("polyglot") + ? config.customPolyglot.map((name) => + engineLanguage( + read(resolve(staticRoot, `languages/${name}.json`)), + ), + ) + : [], + selectedQuoteId: fixture.selectedQuoteId ?? 1, + favoriteQuoteIds: [], + sections: [], }); - - const extra: unknown[] = []; - for (let i = 0; i < (c.extraWords ?? 0); i++) { - const next = await WordsGenerator.getNextWord( - TestWords.words.length, + let generated = content.generate(); + if (fixture.repeat === true) { + generated = content.generate(true); + } + const words = generated.words.map(decodeText); + const strip = (word: string): string => + /[ \n]$/.test(word) ? word.slice(0, -1) : word; + const extra = []; + for (let index = 0; index < (fixture.extraWords ?? 0); index++) { + const next = content.next( + words.length, 100, - TestWords.words.get(TestWords.words.length - 1)?.text ?? "", - TestWords.words.get(TestWords.words.length - 2)?.text, + encodeText(strip(words.at(-1) ?? "")), + words.at(-2) === undefined + ? null + : encodeText(strip(words.at(-2) as string)), ); - TestWords.words.push(next.word, next.sectionIndex); - extra.push(next); + words.push(decodeText(next.word)); + extra.push({ + ...next, + word: decodeText(next.word), + wordRaw: decodeText(next.wordRaw), + }); } - - const quote = state.currentQuote as { id: number; group: number } | null; + const metadata = content.metadata(); return { - ...gen, + ...generated, + words: generated.words.map(decodeText), extra, - allWordsGenerated: WordsGenerator.areAllWordsGenerated(), - currentQuote: quote === null ? null : { id: quote.id, group: quote.group }, + allWordsGenerated: metadata.allWordsGenerated, + currentQuote: + metadata.currentQuote === null + ? null + : { id: metadata.currentQuote.id, group: metadata.currentQuote.group }, }; } - -beforeEach(() => { - TestWords.words.reset(); -}); - -describe("words generator parity", () => { - it.each(cases.map((c) => [c.name, c] as const))("%s", async (name, c) => { - // json round trip on purpose: drops undefined just like the file - const actual = JSON.parse(JSON.stringify(await run(c))) as unknown; - const file = resolve(WORDS_DIR, "__snapshots__", `${name}.json`); - expect(actual).toEqual(JSON.parse(readFileSync(file, "utf-8"))); +describe("native web generator parity", () => { + it.each(cases)("$name", (fixture) => { + const actual = JSON.parse(JSON.stringify(run(fixture))) as unknown; + expect(actual).toEqual( + read(resolve(root, "__snapshots__", `${fixture.name}.json`)), + ); }); }); diff --git a/frontend/__tests__/utils/ip-addresses.spec.ts b/frontend/__tests__/utils/ip-addresses.spec.ts deleted file mode 100644 index 32b3f101f6..0000000000 --- a/frontend/__tests__/utils/ip-addresses.spec.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, it, expect } from "vite-plus/test"; -import * as IpAddresses from "../../src/ts/utils/ip-addresses"; - -const IP_GENERATE_COUNT = 50; - -describe("IP Addresses", () => { - describe("Compressing IPv6", () => { - it("should compress ipv6 according to the official rules", () => { - const rawIps = [ - "0000:0000:0000:0000:0001:0000:0000:0000", - "b70b:ad23:3d4b:23a9:8000:0000:0000:0000", - "ad69:0005:02a4:a8a9:5dae:55f4:d87a:0000", - "0000:0000:0000:0001:0002:0000:0000:0000", - "0000:0000:0000:0000:0000:0000:0000:0000", - "2001:db8:0:0:0:0:2:1", - "2001:db8:0000:1:1:1:1:1", - "9ffd:7895:b4ae:36f6:b50a:8300:0000:0000/88", - ]; - const compressedIps = [ - "::1:0:0:0", - "b70b:ad23:3d4b:23a9:8000::", - "ad69:5:2a4:a8a9:5dae:55f4:d87a:0", - "::1:2:0:0:0", - "::", - "2001:db8::2:1", - "2001:db8:0:1:1:1:1:1", - "9ffd:7895:b4ae:36f6:b50a:8300::/88", - ]; - - for (let i = 0; i < rawIps.length; i++) { - expect(IpAddresses.compressIpv6(rawIps[i] as string)).toEqual( - compressedIps[i], - ); - } - }); - }); - - describe("Generating IPv4", () => { - it("should generate valid IPv4 addresses", () => { - for (let i = 0; i < IP_GENERATE_COUNT; i++) { - const ipAddress = IpAddresses.getRandomIPv4address(); - const parts = ipAddress.split("."); - - expect(parts).toHaveLength(4); - - for (const part of parts) { - const num = Number(part); - expect(num).toBeGreaterThanOrEqual(0); - expect(num).toBeLessThanOrEqual(255); - expect(Number.isInteger(num)).toBe(true); - } - } - }); - }); - - describe("Generating IPv6", () => { - it("should generate valid IPv6 addresses", () => { - for (let i = 0; i < IP_GENERATE_COUNT; i++) { - const ipAddress = IpAddresses.getRandomIPv6address(); - const parts = ipAddress.split(":"); - - expect(parts).toHaveLength(8); - - for (const part of parts) { - expect(part.length).toBeGreaterThanOrEqual(1); - expect(part.length).toBeLessThanOrEqual(4); - - const num = parseInt(part, 16); - expect(num).not.toBeNaN(); - expect(num).toBeGreaterThanOrEqual(0); - expect(num).toBeLessThanOrEqual(0xffff); - } - } - }); - }); - - describe("Address to CIDR", () => { - it("should convert an IPv4 address to CIDR notation", () => { - const ip = "192.168.1.1"; - const cidr = IpAddresses.addressToCIDR(ip); - const ipParts = cidr.split("/"); - expect( - ipParts.length, - "There should only be one '/' in the ip addresss", - ).toEqual(2); - const maskSize = Number(ipParts[1]); - expect(maskSize).not.toBeNaN(); - expect(maskSize).toBeGreaterThanOrEqual(0); - expect(maskSize).toBeLessThanOrEqual(32); - }); - - it("should convert an IPv6 address to CIDR notation", () => { - const ip = "b70b:ad23:3d4b:23a9:8000:0000:0000:0000"; - const cidr = IpAddresses.addressToCIDR(ip); - const ipParts = cidr.split("/"); - expect( - ipParts.length, - "There should only be one '/' in the ip addresss", - ).toEqual(2); - console.log(cidr); - const maskSize = Number(ipParts[1]); - expect(maskSize).not.toBeNaN(); - expect(maskSize).toBeGreaterThanOrEqual(1); - expect(maskSize).toBeLessThanOrEqual(128); - }); - }); -}); diff --git a/frontend/__tests__/utils/strings.spec.ts b/frontend/__tests__/utils/strings.spec.ts index bf3fd90ccf..8a54367bcf 100644 --- a/frontend/__tests__/utils/strings.spec.ts +++ b/frontend/__tests__/utils/strings.spec.ts @@ -507,504 +507,4 @@ describe("string utils", () => { }); }); }); - - describe("isSpace", () => { - it.each([ - // Should return true for directly typable spaces - [" ", 0x0020, "regular space", true], - ["\u2002", 0x2002, "en space", true], - ["\u2003", 0x2003, "em space", true], - ["\u2009", 0x2009, "thin space", true], - [" ", 0x3000, "ideographic space", true], - ["\u00A0", 0x00a0, "non-breaking space", true], - ["\u2007", 0x2007, "figure space", true], - ["\u2008", 0x2008, "punctuation space", true], - ["\u200A", 0x200a, "hair space", true], - ["​", 0x200b, "zero-width space", true], - - // Should return false for other characters - ["\t", 0x0009, "tab", false], - ["a", 0x0061, "letter a", false], - ["A", 0x0041, "letter A", false], - ["1", 0x0031, "digit 1", false], - ["!", 0x0021, "exclamation mark", false], - ["\n", 0x000a, "newline", false], - ["\r", 0x000d, "carriage return", false], - - // Edge cases - ["", null, "empty string", false], - [" ", null, "two spaces", false], - ["ab", null, "two letters", false], - ])( - "should return %s for %s (U+%s - %s)", - ( - char: string, - expectedCodePoint: number | null, - description: string, - expected: boolean, - ) => { - if (expectedCodePoint !== null && char.length === 1) { - expect(char.codePointAt(0)).toBe(expectedCodePoint); - } - expect(Strings.isSpace(char)).toBe(expected); - }, - ); - }); - - describe("areCharactersVisuallyEqual", () => { - it("should return true for identical characters", () => { - expect(Strings.areCharactersVisuallyEqual("a", "a")).toBe(true); - expect(Strings.areCharactersVisuallyEqual("!", "!")).toBe(true); - }); - - it("should return false for different characters", () => { - expect(Strings.areCharactersVisuallyEqual("a", "b")).toBe(false); - expect(Strings.areCharactersVisuallyEqual("!", "?")).toBe(false); - }); - - it("should return true for equivalent apostrophe variants", () => { - expect(Strings.areCharactersVisuallyEqual("'", "'")).toBe(true); - expect(Strings.areCharactersVisuallyEqual("'", "'")).toBe(true); - expect(Strings.areCharactersVisuallyEqual("'", "ʼ")).toBe(true); - }); - - it("should return true for equivalent quote variants", () => { - expect(Strings.areCharactersVisuallyEqual('"', '"')).toBe(true); - expect(Strings.areCharactersVisuallyEqual('"', '"')).toBe(true); - expect(Strings.areCharactersVisuallyEqual('"', "„")).toBe(true); - }); - - it("should return true for equivalent dash variants", () => { - expect(Strings.areCharactersVisuallyEqual("-", "–")).toBe(true); - expect(Strings.areCharactersVisuallyEqual("-", "—")).toBe(true); - expect(Strings.areCharactersVisuallyEqual("–", "—")).toBe(true); - }); - - it("should return true for equivalent comma variants", () => { - expect(Strings.areCharactersVisuallyEqual(",", "‚")).toBe(true); - }); - - it("should return false for characters from different equivalence groups", () => { - expect(Strings.areCharactersVisuallyEqual("'", '"')).toBe(false); - expect(Strings.areCharactersVisuallyEqual("-", "'")).toBe(false); - expect(Strings.areCharactersVisuallyEqual(",", '"')).toBe(false); - }); - - it("should treat any Unicode space as equivalent to a regular space", () => { - // IME-produced spaces must match the U+0020 stored as the word separator - expect(Strings.areCharactersVisuallyEqual(" ", " ")).toBe(true); // ideographic - expect(Strings.areCharactersVisuallyEqual(" ", " ")).toBe(true); // nbsp - expect(Strings.areCharactersVisuallyEqual(" ", " ")).toBe(true); // en space - expect(Strings.areCharactersVisuallyEqual(" ", "a")).toBe(false); - expect(Strings.areCharactersVisuallyEqual(" ", "\n")).toBe(false); - }); - - describe("should check russian specific equivalences", () => { - it.each([ - { - desc: "е and ё are equivalent", - char1: "е", - char2: "ё", - expected: true, - }, - { - desc: "e and ё are equivalent", - char1: "e", - char2: "ё", - expected: true, - }, - { - desc: "е and e are equivalent", - char1: "е", - char2: "e", - expected: true, - }, - { - desc: "non-equivalent characters return false", - char1: "а", - char2: "б", - expected: false, - }, - { - desc: "non-equivalent characters return false (2)", - char1: "a", - char2: "б", - expected: false, - }, - ])("$desc", ({ char1, char2, expected }) => { - expect( - Strings.areCharactersVisuallyEqual(char1, char2, "russian"), - ).toBe(expected); - }); - }); - }); - - describe("countChars", () => { - describe("it should count characters correctly", () => { - const testCases = [ - { - description: "correct, partial, last, shouldnt count", - input: { - inputWord: "hel", - targetWord: "hello ", - creditPartial: false, - }, - expected: { - allCorrect: 3, - correctWord: 0, - incorrect: 0, - extra: 0, - missed: 3, - }, - }, - { - description: "correct, partial, last, should count", - input: { - inputWord: "hel", - targetWord: "hello ", - creditPartial: true, - }, - expected: { - allCorrect: 3, - correctWord: 3, - incorrect: 0, - extra: 0, - missed: 0, - }, - }, - { - description: "correct last", - input: { - inputWord: "hello ", - targetWord: "hello ", - creditPartial: true, - }, - expected: { - allCorrect: 6, - correctWord: 6, - incorrect: 0, - extra: 0, - missed: 0, - }, - }, - { - description: "correct last no space", - input: { - inputWord: "hello", - targetWord: "hello ", - creditPartial: true, - }, - expected: { - allCorrect: 5, - correctWord: 5, - incorrect: 0, - extra: 0, - missed: 0, - }, - }, - { - description: "incorrect, last word, quick end", - input: { - inputWord: "xello", - targetWord: "hello", - creditPartial: false, - }, - expected: { - allCorrect: 4, - correctWord: 0, - incorrect: 1, - extra: 0, - missed: 0, - }, - }, - { - description: "incorrect, last word, early literal space", - input: { - inputWord: "he ", - targetWord: "hello", - creditPartial: false, - }, - expected: { - allCorrect: 2, - correctWord: 0, - incorrect: 1, - extra: 0, - missed: 2, - }, - }, - { - description: "incorrect, last word, noquick end", - input: { - inputWord: "xello ", - targetWord: "hello", - creditPartial: false, - }, - expected: { - allCorrect: 4, - correctWord: 0, - incorrect: 1, - extra: 1, - missed: 0, - }, - }, - { - description: "correct space, incorrect word (literal space)", - input: { - inputWord: "helol ", - targetWord: "hello ", - creditPartial: false, - }, - expected: { - allCorrect: 3, - correctWord: 0, - incorrect: 2, - extra: 1, - missed: 0, - }, - }, - { - description: - "single incorrect char (literal space — stopOnError=word)", - input: { - inputWord: "hxllo ", - targetWord: "hello ", - creditPartial: false, - }, - expected: { - allCorrect: 4, - correctWord: 0, - incorrect: 1, - extra: 1, - missed: 0, - }, - }, - { - description: - "last partial match counts correctWord, no trailing space on target", - input: { - inputWord: "hel", - targetWord: "hello", - creditPartial: true, - }, - expected: { - allCorrect: 3, - correctWord: 3, - incorrect: 0, - extra: 0, - missed: 0, - }, - }, - { - description: "last incorrect partial word doesn't count missed", - input: { - inputWord: "xxx", - targetWord: "hello", - creditPartial: true, - }, - expected: { - allCorrect: 0, - correctWord: 0, - incorrect: 3, - extra: 0, - missed: 0, - }, - }, - { - description: "last partial no count, no trailing space on target", - input: { - inputWord: "hel", - targetWord: "hello", - creditPartial: false, - }, - expected: { - allCorrect: 3, - correctWord: 0, - incorrect: 0, - extra: 0, - missed: 2, - }, - }, - { - description: "partial correct, with space (literal)", - input: { - inputWord: "helxx ", - targetWord: "hello ", - creditPartial: false, - }, - expected: { - allCorrect: 3, - correctWord: 0, - incorrect: 2, - extra: 1, - missed: 0, - }, - }, - { - description: "count extra chars as extra", - input: { - inputWord: "abcx", - targetWord: "abc ", - creditPartial: true, - }, - expected: { - allCorrect: 3, - correctWord: 0, - incorrect: 0, - extra: 1, - missed: 0, - }, - }, - { - description: "count extra chars as extra (with space)", - input: { - inputWord: "abcx ", - targetWord: "abc ", - creditPartial: true, - }, - expected: { - allCorrect: 3, - correctWord: 0, - incorrect: 1, - extra: 1, - missed: 0, - }, - }, - { - description: - "incorrect last word, trailing literal space, timed (stopOnError=word)", - input: { - inputWord: "jhow ", - targetWord: "how", - creditPartial: true, - }, - expected: { - allCorrect: 0, - correctWord: 0, - incorrect: 3, - extra: 2, - missed: 0, - }, - }, - { - description: - "trailing literal space past target — counts as extra (stopOnError=word)", - input: { - inputWord: "xow ", - targetWord: "how", - creditPartial: true, - }, - expected: { - allCorrect: 2, - correctWord: 0, - incorrect: 1, - extra: 1, - missed: 0, - }, - }, - { - description: - "multiple literal trailing spaces on wrong word — all count as extra", - input: { - inputWord: "xonl ", - targetWord: "only ", - creditPartial: true, - }, - expected: { - allCorrect: 0, - correctWord: 0, - incorrect: 4, - extra: 2, - missed: 0, - }, - }, - { - description: - "early literal space at non-space slot with creditPartial — counts as incorrect", - input: { - inputWord: "x ", - targetWord: "get", - creditPartial: true, - }, - expected: { - allCorrect: 0, - correctWord: 0, - incorrect: 2, - extra: 0, - missed: 0, - }, - }, - { - description: - "incorrect word with literal trailing space — uncommitted (stopOnError=word)", - input: { - inputWord: "xello ", - targetWord: "hello ", - creditPartial: false, - }, - expected: { - allCorrect: 4, - correctWord: 0, - incorrect: 1, - extra: 1, - missed: 0, - }, - }, - { - description: "empty input with creditPartial", - input: { - inputWord: "", - targetWord: "hello", - creditPartial: true, - }, - expected: { - allCorrect: 0, - correctWord: 0, - incorrect: 0, - extra: 0, - missed: 0, - }, - }, - { - description: "correct last word, no trailing space anywhere", - input: { - inputWord: "hello", - targetWord: "hello", - creditPartial: false, - }, - expected: { - allCorrect: 5, - correctWord: 5, - incorrect: 0, - extra: 0, - missed: 0, - }, - }, - { - description: "mid-word literal space followed by more input", - input: { - inputWord: "hel o", - targetWord: "hello ", - creditPartial: false, - }, - expected: { - allCorrect: 4, - correctWord: 0, - incorrect: 1, - extra: 0, - missed: 1, - }, - }, - ]; - - it.each(testCases)("$description", ({ input, expected }) => { - expect( - Strings.countChars( - input.inputWord, - input.targetWord, - input.creditPartial, - ), - ).toEqual(expected); - }); - }); - }); }); diff --git a/frontend/__tests__/vite-plugins/service-worker.spec.ts b/frontend/__tests__/vite-plugins/service-worker.spec.ts index 24dd561fea..9790f14393 100644 --- a/frontend/__tests__/vite-plugins/service-worker.spec.ts +++ b/frontend/__tests__/vite-plugins/service-worker.spec.ts @@ -88,6 +88,31 @@ describe("service-worker precache", () => { ).toThrow(/renamed\.client\.tsx/); }); + it("precaches the browser engine, its glue and paired Wasm", () => { + const engine = "packages/typing-engine/src/browser.client.ts"; + const nativeBundle = { + ...bundle, + engine: { + ...chunk("assets/browser.client-hash.js", null, { + imports: ["assets/glue-hash.js"], + }), + facadeModuleId: `/repo/${engine}`, + }, + glue: chunk("assets/glue-hash.js", null), + wasm: { type: "asset" as const, fileName: "assets/engine_bg-hash.wasm" }, + }; + const files = precacheFromBundle(nativeBundle, [engine]); + expect(files).toEqual( + expect.arrayContaining([ + "/assets/browser.client-hash.js", + "/assets/glue-hash.js", + "/assets/engine_bg-hash.wasm", + ]), + ); + expect(files).not.toContain("/assets/settings-only.js"); + expect(() => precacheFromBundle(bundle, [engine])).toThrow(engine); + }); + it("adds the default language, fonts and manifest", () => { expect(offlineStaticFiles("0123456789abcdef0123")).toEqual([ "/languages/english.json?v=0123456789abcdef", diff --git a/frontend/e2e/engine-content-website.migration.ts b/frontend/e2e/engine-content-website.migration.ts new file mode 100644 index 0000000000..d1590cc170 --- /dev/null +++ b/frontend/e2e/engine-content-website.migration.ts @@ -0,0 +1,133 @@ +import type { Route } from "playwright/test"; +import type { WebLog } from "@oxytype/typing-engine/text"; +import { expect, openTest, test } from "./fixtures"; +test.use({ + config: { mode: "time", time: 600, funbox: ["poetry"], smoothCaret: "off" }, +}); + +async function poem(route: Route): Promise { + await route.fulfill({ + headers: { "access-control-allow-origin": "*" }, + json: [ + { + title: "Fixture", + author: "Fixture", + lines: ["one ".repeat(100).trim()], + }, + ], + }); +} +async function holdContinuation( + page: import("playwright/test").Page, +): Promise> { + const pending = Promise.withResolvers(); + let requests = 0; + await page.route("https://poetrydb.org/random", async (route) => { + requests++; + if (requests === 2) { + pending.resolve(route); + } else { + await poem(route); + } + }); + await openTest(page); + await page.locator("#words .word").first().click(); + await page.keyboard.insertText("one ".repeat(102)); + return pending; +} + +test("resumes every original batch unit after a delayed native content reply", async ({ + page, +}) => { + const pending = await holdContinuation(page); + const source = await pending.promise; + await expect + .poll(async () => + page.evaluate( + () => + ( + window as unknown as { + engineMigrationState: () => { wordIndex: number }; + } + ).engineMigrationState().wordIndex, + ), + ) + .toBe(99); + await poem(source); + await expect + .poll(async () => + page.evaluate( + () => + ( + window as unknown as { + engineMigrationState: () => { wordIndex: number }; + } + ).engineMigrationState().wordIndex, + ), + ) + .toBe(102); + const log = await page.evaluate(() => + (window as unknown as { currentEventLog: () => WebLog }).currentEventLog(), + ); + const inputs = log.events.filter((event) => event.type === "input"); + expect(inputs).toHaveLength(408); + expect(new Set(inputs.map((event) => event.testMs))).toEqual(new Set([0])); + await expect(page.locator("#wordsInput")).toHaveValue(" "); +}); + +test("cancels a pending batch on navigation without an unhandled rejection or stale input", async ({ + page, +}) => { + const errors: string[] = []; + page.on("pageerror", (error) => { + errors.push(error.message); + }); + const pending = await holdContinuation(page); + const source = await pending.promise; + const failed = page.waitForEvent("requestfailed", { + predicate: (request) => request === source.request(), + }); + await page.getByRole("link", { name: "Settings", exact: true }).click(); + await failed; + await expect(page.locator("#group_behavior")).toBeVisible(); + await page.goto("/"); + await expect(page.locator("#words .word.active")).toHaveAttribute( + "data-wordindex", + "0", + ); + await expect(page.locator("#wordsInput")).toHaveValue(" "); + expect(errors).toEqual([]); +}); + +test("recovers from an unavailable active source without scoring a typing failure", async ({ + page, +}) => { + const errors: string[] = []; + page.on("pageerror", (error) => { + errors.push(error.message); + }); + const pending = await holdContinuation(page); + const source = await pending.promise; + await source.fulfill({ + status: 503, + headers: { "access-control-allow-origin": "*" }, + body: "unavailable", + }); + await expect + .poll(async () => + page.evaluate( + () => + JSON.parse(localStorage.getItem("config") ?? "{}") as { + funbox?: string[]; + }, + ), + ) + .toMatchObject({ funbox: [] }); + await expect(page.locator("#typingTest")).toHaveCSS("opacity", "1"); + await expect(page.locator("#words .word.active")).toHaveAttribute( + "data-wordindex", + "0", + ); + await expect(page.locator("#result")).toBeHidden(); + expect(errors).toEqual([]); +}); diff --git a/frontend/e2e/engine-input-contract.migration.ts b/frontend/e2e/engine-input-contract.migration.ts index dfffb84093..4bdc8d43b7 100644 --- a/frontend/e2e/engine-input-contract.migration.ts +++ b/frontend/e2e/engine-input-contract.migration.ts @@ -1,4 +1,4 @@ -import type { EventLog } from "@oxytype/typing-core/events/types"; +import type { EventLog } from "@oxytype/web-shared/events/types"; import { expect, openTest, test } from "./fixtures"; test.use({ diff --git a/frontend/e2e/engine-input-observations.migration.ts b/frontend/e2e/engine-input-observations.migration.ts new file mode 100644 index 0000000000..c1fcb685d8 --- /dev/null +++ b/frontend/e2e/engine-input-observations.migration.ts @@ -0,0 +1,171 @@ +import reference from "../../packages/typing-fixtures/oracle/browser-final-word-batch.json" with { type: "json" }; +import type { EventLog } from "@oxytype/web-shared/events/types"; +import { expect, openTest, test } from "./fixtures"; + +test.use({ + config: { mode: "custom", difficulty: "normal", smoothCaret: "off" }, +}); +test("records the final-word DOM reset inside a multi-character commit", async ({ + page, +}) => { + await page.addInitScript(() => { + localStorage.setItem( + "customTextSettings", + JSON.stringify({ + text: ["one"], + mode: "repeat", + limit: { mode: "word", value: 1 }, + pipeDelimiter: false, + }), + ); + }); + await openTest(page); + await expect(page.locator("#words .word").first()).toHaveText("one"); + await page.locator("#words").click(); + await page.keyboard.insertText("one extra"); + await expect(page.locator("#wordsInput")).toHaveValue(" extra"); + const log = await page.evaluate(() => + ( + window as unknown as { currentEventLog: () => EventLog } + ).currentEventLog(), + ); + const input = log.events.filter((event) => event.type === "input"); + expect(input).toEqual(reference.input); +}); + +test.describe("native input projection", () => { + test("keeps blind-mode commit styling after letter rendering", async ({ + page, + }) => { + await page.addInitScript(() => { + const config = JSON.parse( + localStorage.getItem("config") ?? "{}", + ) as Record; + config["blindMode"] = true; + localStorage.setItem("config", JSON.stringify(config)); + localStorage.setItem( + "customTextSettings", + JSON.stringify({ + text: ["one", "two"], + mode: "repeat", + limit: { mode: "word", value: 2 }, + pipeDelimiter: false, + }), + ); + }); + await openTest(page); + const word = page.locator('#words .word[data-wordindex="0"]'); + await word.click(); + await page.keyboard.insertText("x "); + await expect(word.locator("letter.correct")).toHaveCount(3); + await expect(page.locator("#words .word.active")).toHaveAttribute( + "data-wordindex", + "1", + ); + }); + test("ends a complete composition exactly once through the browser listeners", async ({ + page, + }) => { + await page.addInitScript(() => { + localStorage.setItem( + "customTextSettings", + JSON.stringify({ + text: ["日本"], + mode: "repeat", + limit: { mode: "word", value: 1 }, + pipeDelimiter: false, + }), + ); + }); + await openTest(page); + await page.locator("#words .word").first().click(); + await page.locator("#wordsInput").evaluate((element) => { + const input = element as HTMLTextAreaElement; + input.dispatchEvent( + new CompositionEvent("compositionstart", { data: "" }), + ); + input.value = " 日本"; + input.dispatchEvent( + new CompositionEvent("compositionupdate", { data: "日本" }), + ); + input.dispatchEvent( + new InputEvent("input", { + inputType: "insertCompositionText", + data: "日本", + isComposing: true, + }), + ); + }); + await expect(page.locator("#result")).toBeVisible(); + const log = await page.evaluate(() => + (window as unknown as { lastEventLog: () => EventLog }).lastEventLog(), + ); + const inserts = log.events.filter((event) => event.type === "input"); + expect(inserts.map((event) => event.data)).toMatchObject([ + { + data: "日", + wordIndex: 0, + charIndex: 0, + inputValue: "日", + isCompositionEnding: true, + }, + { + data: "本", + wordIndex: 0, + charIndex: 1, + inputValue: "日本", + isCompositionEnding: true, + }, + ]); + expect( + log.events.filter( + (event) => event.type === "timer" && event.data.event === "end", + ), + ).toHaveLength(1); + }); +}); + +test.describe("zen native projection", () => { + test.use({ + config: { mode: "zen", smoothCaret: "off", quickRestart: "off" }, + }); + test("renders consecutive zen words, backward navigation and restart without generated targets", async ({ + page, + }) => { + const errors: string[] = []; + page.on("pageerror", (error) => { + errors.push(error.message); + }); + await openTest(page); + for (let run = 0; run < 2; run++) { + await page.locator("#words").click(); + await page.keyboard.type("one two "); + await expect(page.locator("#words .word.active")).toHaveAttribute( + "data-wordindex", + "2", + ); + await page.keyboard.press("Backspace"); + await expect(page.locator("#wordsInput")).toHaveValue(" two"); + await page.keyboard.type(" three "); + await expect(page.locator("#words .word.active")).toHaveAttribute( + "data-wordindex", + "3", + ); + const log = await page.evaluate(() => + ( + window as unknown as { currentEventLog: () => EventLog } + ).currentEventLog(), + ); + expect(log.context.targetWords).toEqual([]); + expect( + log.events.filter( + (event) => event.type === "input" && "lastWord" in event.data, + ), + ).toEqual([]); + await page.locator("#restartTestButton").click(); + await expect(page.locator("#typingTest")).toHaveCSS("opacity", "1"); + await expect(page.locator("#words .word")).toHaveCount(1); + } + expect(errors).toEqual([]); + }); +}); diff --git a/frontend/e2e/engine-lifecycle.migration.ts b/frontend/e2e/engine-lifecycle.migration.ts index 48edf209a2..cf9e930e1b 100644 --- a/frontend/e2e/engine-lifecycle.migration.ts +++ b/frontend/e2e/engine-lifecycle.migration.ts @@ -17,6 +17,13 @@ test("browser Wasm long sessions and repeated ownership release retain bounded w await page.getByTestId("engine-proof-result").innerText(), ) as LifecycleProof; expect(output.invalid).toBe(3); + expect(output.hostQueries).toEqual({ + compositionReady: true, + metadata: { allWordsGenerated: false, hasTab: true, hasNewline: true }, + burst: 240, + canDelete: false, + timeLimitSeconds: null, + }); expect(output.events).toBeGreaterThanOrEqual(20000); expect(output.live).toHaveLength(10); for (const [index, metrics] of output.live.entries()) { diff --git a/frontend/e2e/engine-performance.migration.ts b/frontend/e2e/engine-performance.migration.ts index 74ab85e762..2a671f9091 100644 --- a/frontend/e2e/engine-performance.migration.ts +++ b/frontend/e2e/engine-performance.migration.ts @@ -1,3 +1,5 @@ +import budgets from "../../docs/rust-engine/performance-budgets.json" with { type: "json" }; +import { summarize } from "../scripts/migration-performance"; import { writeFile } from "node:fs/promises"; import { expect, test } from "playwright/test"; @@ -8,6 +10,10 @@ test("typing engine baseline: input paint, restart, retained heap", async ({ }) => { const output = process.env["ENGINE_PERFORMANCE_OUTPUT"]; if (output === undefined) throw new Error("Set ENGINE_PERFORMANCE_OUTPUT"); + const errors: string[] = []; + page.on("pageerror", (error) => { + errors.push(error.message); + }); await page.addInitScript(() => { localStorage.setItem( "acceptedCookies", @@ -24,6 +30,13 @@ test("typing engine baseline: input paint, restart, retained heap", async ({ const cdp = await context.newCDPSession(page); await cdp.send("Performance.enable"); await cdp.send("HeapProfiler.collectGarbage"); + const wasmMemory = async (): Promise => + page.evaluate(() => + ( + window as unknown as { engineMigrationMemory: () => number } + ).engineMigrationMemory(), + ); + const initialWasm = await wasmMemory(); const initialHeap = (await cdp.send("Runtime.getHeapUsage")).usedSize; const inputToPaint: number[] = []; const restart: number[] = []; @@ -61,11 +74,17 @@ test("typing engine baseline: input paint, restart, retained heap", async ({ } // Real keyboard observations; no engine-internal shortcut for the long run. await page.keyboard.type("a ".repeat(1000)); + await expect(page.locator("#words .word.active")).toHaveAttribute( + "data-wordindex", + "1000", + ); await cdp.send("HeapProfiler.collectGarbage"); + const activeWasm = await wasmMemory(); const activeHeap = (await cdp.send("Runtime.getHeapUsage")).usedSize; await page.locator("#restartTestButton").click(); await expect(page.locator("#typingTest")).toHaveCSS("opacity", "1"); await cdp.send("HeapProfiler.collectGarbage"); + const resetWasm = await wasmMemory(); const resetHeap = (await cdp.send("Runtime.getHeapUsage")).usedSize; const assets = await page.evaluate(() => performance @@ -82,6 +101,80 @@ test("typing engine baseline: input paint, restart, retained heap", async ({ ); await writeFile( output, - `${JSON.stringify({ browser: context.browser()?.version(), fixture: "built Start Worker; desktop Chromium; zen; 20 input/restart samples; double-rAF paint; 2000 keyboard characters; forced GC", inputToPaint, restart, initialHeap, activeHeap, resetHeap, assets }, null, 2)}\n`, + `${JSON.stringify({ browser: context.browser()?.version(), fixture: "built Start Worker; desktop Chromium; zen; 20 input/restart samples; double-rAF paint; 2000 keyboard characters; forced GC", inputToPaint, restart, initialHeap, activeHeap, resetHeap, initialWasm, activeWasm, resetWasm, assets, errors }, null, 2)}\n`, ); + expect(errors).toEqual([]); + if (process.env["ENGINE_ENFORCE_PERFORMANCE"] === "true") { + expect(summarize(inputToPaint).p95).toBeLessThanOrEqual( + budgets.inputToPaintP95Ms, + ); + expect(summarize(restart).median).toBeLessThanOrEqual( + budgets.restartMedianMs, + ); + expect(summarize(restart).p95).toBeLessThanOrEqual(budgets.restartP95Ms); + expect(activeHeap).toBeLessThanOrEqual(budgets.activeHeapBytes); + expect(resetHeap).toBeLessThanOrEqual(budgets.resetHeapBytes); + expect(resetWasm).toBeLessThanOrEqual(budgets.wasmHeapBytesAfterReset); + } +}); + +test("repeated website zen sessions keep bounded reset heaps", async ({ + page, + context, +}) => { + test.setTimeout(240_000); + await page.addInitScript(() => { + localStorage.setItem( + "acceptedCookies", + JSON.stringify({ security: false, analytics: false, sentry: false }), + ); + localStorage.setItem( + "config", + JSON.stringify({ mode: "zen", smoothCaret: "off", quickRestart: "off" }), + ); + }); + const errors: string[] = []; + page.on("pageerror", (error) => { + errors.push(error.message); + }); + await page.goto("/"); + await expect(page.locator("#typingTest")).toHaveCSS("opacity", "1"); + const cdp = await context.newCDPSession(page); + const samples = []; + for (let cycle = 0; cycle < 3; cycle++) { + await page.locator("#words").click(); + await page.keyboard.type("a ".repeat(1000)); + await expect(page.locator("#words .word.active")).toHaveAttribute( + "data-wordindex", + "1000", + ); + await page.locator("#restartTestButton").click(); + await expect(page.locator("#typingTest")).toHaveCSS("opacity", "1"); + await expect(page.locator("#wordsInput")).toHaveValue(" "); + await cdp.send("HeapProfiler.collectGarbage"); + samples.push({ + heap: (await cdp.send("Runtime.getHeapUsage")).usedSize, + wasm: await page.evaluate(() => + ( + window as unknown as { engineMigrationMemory: () => number } + ).engineMigrationMemory(), + ), + }); + } + const output = process.env["ENGINE_WEBSITE_SOAK_OUTPUT"]; + if (output !== undefined) { + await writeFile( + output, + `${JSON.stringify({ fixture: "3 real-keyboard 2000-unit zen sessions; restart and forced GC", samples, errors }, null, 2)}\n`, + ); + } + expect(errors).toEqual([]); + if (process.env["ENGINE_ENFORCE_PERFORMANCE"] === "true") { + expect( + Math.max(...samples.map((sample) => sample.wasm)), + ).toBeLessThanOrEqual(budgets.wasmHeapBytesAfterReset); + expect( + Math.max(...samples.map((sample) => sample.heap)), + ).toBeLessThanOrEqual(budgets.resetHeapBytes); + } }); diff --git a/frontend/e2e/engine-proof.migration.ts b/frontend/e2e/engine-proof.migration.ts index 7144668b5a..6f593a0899 100644 --- a/frontend/e2e/engine-proof.migration.ts +++ b/frontend/e2e/engine-proof.migration.ts @@ -5,7 +5,7 @@ import budgets from "../../docs/rust-engine/performance-budgets.json" with { typ import { expect, test } from "playwright/test"; import { toWebLog } from "@oxytype/typing-engine/text"; import { CompletedEventSchema } from "@oxytype/schemas/results"; -import { hashResult } from "@oxytype/typing-core/result-hash"; +import { hashResult } from "@oxytype/web-shared/result-hash"; import { getResultFailure } from "../../backend/src/anticheat/result"; const root = resolve(import.meta.dirname, "../../packages/typing-fixtures"); @@ -1184,3 +1184,84 @@ test("quote length changes refresh only the owned queue and preserve cached-lang })), }); }); + +test("quote catalog reads retain the original selected-object normalization through Wasm", async ({ + page, +}) => { + const reference = JSON.parse( + await readFile( + resolve(root, "oracle/quote-catalog-selection.json"), + "utf8", + ), + ) as { + asset: { quotes: { text: string; britishText: string }[] }; + stages: { british: boolean; expected: unknown }[]; + }; + const units = (value: string): number[] => + Array.from({ length: value.length }, (_, index) => value.charCodeAt(index)); + const neutral = (value: unknown, key = ""): unknown => { + if ( + typeof value === "string" && + ["text", "britishText", "textSplit", "words"].includes(key) + ) { + return units(value); + } + if (Array.isArray(value)) return value.map((item) => neutral(item, key)); + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([name, item]) => [ + name, + neutral(item, name), + ]), + ); + } + return value; + }; + await page.goto("/engine-proof"); + await page.getByRole("textbox", { name: "Command fixture" }).fill( + JSON.stringify({ + asset: neutral(reference.asset), + stages: reference.stages.map(({ british }) => ({ british })), + }), + ); + await page + .getByRole("button", { name: "Run quote catalog", exact: true }) + .click(); + await expect(page.getByTestId("engine-proof-result")).not.toBeEmpty(); + await expect(page.getByTestId("engine-proof-error")).toBeEmpty(); + expect( + JSON.parse(await page.getByTestId("engine-proof-result").innerText()), + ).toEqual(reference.stages.map((stage) => neutral(stage.expected))); +}); + +test("native inspector preserves every original log prefix through browser Wasm", async ({ + page, +}) => { + const units = (value: string): number[] => + Array.from({ length: value.length }, (_, index) => value.charCodeAt(index)); + const references = JSON.parse( + await readFile(resolve(root, "oracle/inspection.json"), "utf8"), + ) as { log: unknown; expected: { inputs: string[] } }[]; + await page.goto("/engine-proof"); + await page + .getByRole("textbox", { name: "Command fixture" }) + .fill( + JSON.stringify( + references.map(({ log }) => ({ log, queries: [{ op: "inspect" }] })), + ), + ); + await page.getByRole("button", { name: "Run analysis", exact: true }).click(); + await expect(page.getByTestId("engine-proof-error")).toBeEmpty(); + await expect(page.getByTestId("engine-proof-result")).not.toBeEmpty(); + const actual = JSON.parse( + await page.getByTestId("engine-proof-result").innerText(), + ) as unknown; + expect(actual).toEqual( + references.map(({ expected }) => [ + { + op: "inspect", + inspection: { ...expected, inputs: expected.inputs.map(units) }, + }, + ]), + ); +}); diff --git a/frontend/e2e/engine-results-website.migration.ts b/frontend/e2e/engine-results-website.migration.ts new file mode 100644 index 0000000000..1984644ef8 --- /dev/null +++ b/frontend/e2e/engine-results-website.migration.ts @@ -0,0 +1,131 @@ +import { completeWords, expect, openTest, test } from "./fixtures"; + +test("signed-out native results retain history, clipboard export and replay seeking", async ({ + page, + context, +}) => { + const errors: string[] = []; + page.on("pageerror", (error) => { + errors.push(error.message); + }); + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await openTest(page); + const targets = await page.locator("#words .word").allTextContents(); + await completeWords(page); + await page.locator("#showWordHistoryButton").click(); + await expect(page.locator("#resultWordsHistory")).toBeVisible(); + await expect(page.locator("#resultWordsHistory .word.nocursor")).toHaveCount( + 15, + ); + await page.locator("#copyWordsListButton").click(); + await expect + .poll(async () => page.evaluate(async () => navigator.clipboard.readText())) + .toBe(targets.join(" ")); + await page.locator("#watchReplayButton").click(); + await expect(page.locator("#replayWords .word")).toHaveCount(15); + await page.locator("#playpauseReplayButton").click(); + await expect(page.locator("#playpauseReplayButton")).toHaveAttribute( + "aria-label", + "Pause replay", + ); + await expect( + page.locator("#replayWords letter.correct").first(), + ).toBeVisible(); + await page.locator("#playpauseReplayButton").click(); + await expect(page.locator("#playpauseReplayButton")).toHaveAttribute( + "aria-label", + "Resume replay", + ); + await page + .locator("#replayWords .word") + .nth(1) + .locator("letter") + .first() + .click(); + await expect( + page.locator("#replayWords .word").first().locator("letter.correct"), + ).toHaveCount((targets[0]?.length ?? 0) + 1); + await page.locator("#playpauseReplayButton").click(); + await expect(page.locator("#playpauseReplayButton")).toHaveAttribute( + "aria-label", + "Start replay", + { timeout: 15_000 }, + ); + await expect(page.locator("#replayWords letter.incorrect")).toHaveCount(0); + await expect(page.locator("#replayWords letter.correct")).toHaveCount( + targets.join(" ").length, + ); + expect(errors).toEqual([]); +}); + +test.describe("native completion transport", () => { + test.use({ authenticated: true, startSession: "browser-typing" }); + + test("retries exactly the same completed payload after a temporary save error", async ({ + page, + }) => { + const { rpcInput, rpcPath, fulfillOperation } = + await import("../start-e2e/rpc-fixtures"); + const { toCrossJSONAsync } = await import("seroval"); + const payloads: unknown[] = []; + await page.route(`**${rpcPath("resultsAdd")}`, async (route) => { + payloads.push(rpcInput(route.request())); + if (payloads.length === 1) { + await route.fulfill({ + contentType: "application/json", + headers: { "x-tss-serialized": "true" }, + body: JSON.stringify( + await toCrossJSONAsync({ + result: { + ok: false, + status: 503, + message: "Temporary save failure", + }, + context: {}, + }), + ), + }); + return; + } + await fulfillOperation( + route, + { insertedId: "native_retry", isPb: false, tagPbs: [] }, + "Result saved", + ); + }); + await openTest(page); + await page.waitForLoadState("networkidle"); + await completeWords(page); + await expect(page.locator("#retrySavingResultButton")).toBeVisible(); + await page.locator("#retrySavingResultButton").click(); + await expect(page.locator("#retrySavingResultButton")).toBeHidden(); + await expect.poll(() => payloads.length).toBe(2); + expect(payloads[1]).toEqual(payloads[0]); + await expect(page.getByText("Result saved", { exact: true })).toBeVisible(); + }); + + test.describe("invalid short test", () => { + test.use({ config: { mode: "words", words: 5, smoothCaret: "off" } }); + test("shows native invalidity and never submits the result", async ({ + page, + }) => { + const { rpcPath } = await import("../start-e2e/rpc-fixtures"); + const submissions: string[] = []; + page.on("request", (request) => { + if ( + new URL(request.url()).pathname === rpcPath("resultsAdd") && + request.method() === "POST" + ) { + submissions.push(request.url()); + } + }); + await openTest(page); + await page.waitForLoadState("networkidle"); + await completeWords(page); + await expect( + page.getByText("Test invalid - too short", { exact: true }), + ).toBeVisible(); + expect(submissions).toEqual([]); + }); + }); +}); diff --git a/frontend/e2e/engine-website.migration.ts b/frontend/e2e/engine-website.migration.ts new file mode 100644 index 0000000000..d4e9a8d92d --- /dev/null +++ b/frontend/e2e/engine-website.migration.ts @@ -0,0 +1,68 @@ +import type { Route } from "playwright/test"; +import { expect, test } from "./fixtures"; + +test.use({ config: { mode: "words", words: 15, funbox: ["ALL_CAPS"] } }); + +async function expectSavedFunbox( + page: import("playwright/test").Page, +): Promise { + expect( + await page.evaluate( + () => + ( + JSON.parse(localStorage.getItem("config") ?? "{}") as { + funbox: string[]; + } + ).funbox, + ), + ).toEqual(["ALL_CAPS"]); + const word = await page.locator("#words .word").first().textContent(); + expect(word).toBeTruthy(); + expect(word).toBe(word?.toUpperCase()); +} + +test("the website cannot accept typing before its Wasm engine is ready", async ({ + page, +}) => { + const download = Promise.withResolvers(); + await page.route("**/*.wasm", (route) => { + download.resolve(route); + }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + const request = await download.promise; + await expect(page.locator("#wordsInput")).toBeDisabled(); + await expect(page.locator("#words .word")).toHaveCount(0); + await request.continue(); + await expect(page.locator("#wordsInput")).toBeEnabled(); + await expect(page.locator("#typingTest")).toHaveCSS("opacity", "1"); + await expectSavedFunbox(page); + const word = page.locator("#words .word").first(); + await word.click(); + const character = (await word.textContent())?.[0]; + if (character === undefined) throw new Error("Missing prepared word"); + await page.keyboard.type(character); + await expect(word.locator("letter.correct")).toHaveCount(1); +}); + +test("a failed engine download shows the existing failure UI and Restart retries", async ({ + page, +}) => { + await page.route("**/*.wasm", async (route) => { + await route.fulfill({ status: 503, body: "engine unavailable" }); + }); + await page.goto("/"); + await expect(page.locator("#testInitFailed")).toBeVisible(); + await expect(page.locator("#testInitFailed")).toContainText( + "Typing engine download failed: 503", + ); + await expect(page.locator("#wordsInput")).toBeDisabled(); + await page.unroute("**/*.wasm"); + await page + .locator("#testInitFailed") + .getByRole("button", { name: "Restart", exact: true }) + .click(); + await expect(page.locator("#testInitFailed")).toBeHidden(); + await expect(page.locator("#wordsInput")).toBeEnabled(); + await expect(page.locator("#words .word").first()).toBeVisible(); + await expectSavedFunbox(page); +}); diff --git a/frontend/e2e/readiness.migration.ts b/frontend/e2e/readiness.migration.ts index fa998f3b62..e2567dfef4 100644 --- a/frontend/e2e/readiness.migration.ts +++ b/frontend/e2e/readiness.migration.ts @@ -1,3 +1,5 @@ +import budgets from "../../docs/rust-engine/performance-budgets.json" with { type: "json" }; +import { summarize } from "../scripts/migration-performance"; import { writeFile } from "node:fs/promises"; import { expect, test } from "playwright/test"; @@ -53,4 +55,10 @@ test("repeated production cold/warm input and public route readiness", async ({ output, `${JSON.stringify({ fixture: "production assets, guest API fixtures, desktop Chromium, HTTP cache cold/warm, SW bypass", cold, warm, settings }, null, 2)}\n`, ); + if (process.env["ENGINE_ENFORCE_PERFORMANCE"] === "true") { + expect(summarize(cold).median).toBeLessThanOrEqual(budgets.coldMedianMs); + expect(summarize(cold).p95).toBeLessThanOrEqual(budgets.coldP95Ms); + expect(summarize(warm).median).toBeLessThanOrEqual(budgets.warmMedianMs); + expect(summarize(warm).p95).toBeLessThanOrEqual(budgets.warmP95Ms); + } }); diff --git a/frontend/e2e/save-result.spec.ts b/frontend/e2e/save-result.spec.ts index f00e8474bc..27baa24831 100644 --- a/frontend/e2e/save-result.spec.ts +++ b/frontend/e2e/save-result.spec.ts @@ -44,6 +44,7 @@ test("logged-in completion submits a result and applies save acknowledgement", a await fulfillOperation(route, saved.data, saved.message); }); await openTest(page); + await page.waitForLoadState("networkidle"); const submission = page.waitForRequest( (request) => new URL(request.url()).pathname === submissionPath && diff --git a/frontend/package.json b/frontend/package.json index 2257c77039..0f911d66d6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -59,9 +59,9 @@ "@oxytype/contracts": "workspace:*", "@oxytype/funbox": "workspace:*", "@oxytype/schemas": "workspace:*", - "@oxytype/typing-core": "workspace:*", "@oxytype/typing-engine": "workspace:*", "@oxytype/util": "workspace:*", + "@oxytype/web-shared": "workspace:*", "@sentry/browser": "11.1.0", "@sentry/vite-plugin": "5.4.0", "@solid-primitives/refs": "1.1.4", diff --git a/frontend/scripts/build-update-fixtures.ts b/frontend/scripts/build-update-fixtures.ts index 8dc5cc51ef..62d7df12cd 100644 --- a/frontend/scripts/build-update-fixtures.ts +++ b/frontend/scripts/build-update-fixtures.ts @@ -1,9 +1,11 @@ import { spawnSync } from "node:child_process"; -import { cp, rm } from "node:fs/promises"; +import { cp, readdir, readFile, rm } from "node:fs/promises"; import { resolve } from "node:path"; +import { preserveAssets, type PreviousAssets } from "./preserve-assets"; async function main(): Promise { const root = resolve(import.meta.dirname, ".."); + let previous: PreviousAssets = new Map(); for (const build of ["update-a", "update-b"]) { const env: NodeJS.ProcessEnv = { ...process.env, @@ -24,6 +26,18 @@ async function main(): Promise { const target = resolve(root, "dist", build); await rm(target, { recursive: true, force: true }); await cp(resolve(root, "dist/start"), target, { recursive: true }); + const client = resolve(target, "client"); + await preserveAssets(client, previous); + previous = new Map( + await Promise.all( + (await readdir(resolve(client, "assets"))) + .filter((name) => /\.(?:js|css|woff2|wasm)$/.test(name)) + .map(async (name): Promise<[string, Uint8Array]> => [ + `/assets/${name}`, + new Uint8Array(await readFile(resolve(client, "assets", name))), + ]), + ), + ); } } void main().catch((error: unknown) => { diff --git a/frontend/scripts/preserve-assets.ts b/frontend/scripts/preserve-assets.ts index f831ff2bd2..56bd4aa6ef 100644 --- a/frontend/scripts/preserve-assets.ts +++ b/frontend/scripts/preserve-assets.ts @@ -4,9 +4,10 @@ import { dirname, resolve } from "node:path"; import { z } from "zod"; const historyPath = "/_asset-history.json"; -const safePath = /^\/(?:assets|js|css|webfonts)\/[\w.-]+\.(?:js|css|woff2)$/; +const safePath = + /^\/(?:assets|js|css|webfonts)\/[\w.-]+\.(?:js|css|woff2|wasm)$/; const legacyHashed = - /^\/(?:js|css|webfonts)\/[\w.-]+\.[\w-]{8}\.(?:js|css|woff2)$/; + /^\/(?:js|css|webfonts)\/[\w.-]+\.[\w-]{8}\.(?:js|css|woff2|wasm)$/; const historySchema = z.object({ files: z.record( z.string().regex(safePath), @@ -101,7 +102,7 @@ export async function snapshotAssets( function references(source: string, base: string, origin: string): string[] { const matches = source.matchAll( - /["'`(]([^"'`\s()<>]+\.(?:js|css|woff2))(?:[?#][^"'`\s()<>]*)?["'`)]/g, + /["'`(]([^"'`\s()<>]+\.(?:js|css|woff2|wasm))(?:[?#][^"'`\s()<>]*)?["'`)]/g, ); return [...matches] .flatMap((match) => (match[1] ?? "").split(",")) @@ -142,7 +143,7 @@ export async function preserveAssets( } const paths = new Set(previous.keys()); for (const name of await readdir(resolve(directory, "assets"))) { - if (/\.(?:js|css|woff2)$/.test(name)) paths.add(`/assets/${name}`); + if (/\.(?:js|css|woff2|wasm)$/.test(name)) paths.add(`/assets/${name}`); } const files: Record = {}; for (const path of [...paths].sort()) { diff --git a/frontend/src/app/default-theme.ts b/frontend/src/app/default-theme.ts index e85e9a728e..41c63c862d 100644 --- a/frontend/src/app/default-theme.ts +++ b/frontend/src/app/default-theme.ts @@ -1,5 +1,5 @@ import { ThemeName } from "@oxytype/schemas/configs"; -import { defaultThemes } from "@oxytype/typing-core/config/default-config"; +import { defaultThemes } from "@oxytype/web-shared/config/default-config"; import { tryCatchSync } from "@oxytype/util/trycatch"; // Also read and written by the document theme bootstrap script. diff --git a/frontend/src/app/navigation-guard.client.ts b/frontend/src/app/navigation-guard.client.ts index 858535d358..79a9dd1fee 100644 --- a/frontend/src/app/navigation-guard.client.ts +++ b/frontend/src/app/navigation-guard.client.ts @@ -5,7 +5,7 @@ import { isTestRestarting, isResultCalculating, } from "../ts/states/test"; -import { isFunboxActive } from "../features/typing/runtime/funbox/list"; +import { isNoQuitActive } from "../features/typing/runtime/session.client"; /** Router-independent test guard; hosts add their own transition state. */ export function canNavigate(options: NavigateOptions = {}): boolean { @@ -13,7 +13,7 @@ export function canNavigate(options: NavigateOptions = {}): boolean { return false; } - if (isTestActive() && isFunboxActive("no_quit")) { + if (isTestActive() && isNoQuitActive()) { showNoticeNotification( "No quit funbox is active. Please finish the test.", { important: true }, diff --git a/frontend/src/app/platform-runtime.client.tsx b/frontend/src/app/platform-runtime.client.tsx index 8b9e10fab0..65ed957b83 100644 --- a/frontend/src/app/platform-runtime.client.tsx +++ b/frontend/src/app/platform-runtime.client.tsx @@ -1,6 +1,7 @@ import { createEffect, onCleanup, onMount } from "solid-js"; import { type JSXElement } from "solid-js"; +import { getBrowserEngine } from "../features/typing/engine.client"; import { applyEngineSettings } from "../ts/anim"; import { Theme } from "../ts/components/core/Theme"; import { ThemeEvents } from "../ts/components/core/ThemeEvents"; @@ -37,6 +38,13 @@ export default function BrowserRuntime(): JSXElement { }), ); applyEngineSettings(); + // Policies are shared with the typing route; preferences/themes still load + // independently, and the typing failure UI owns download retry. + if (window.location.pathname !== "/engine-proof") { + void getBrowserEngine() + .load() + .catch(() => undefined); + } void loadFromLocalStorage().catch((error: unknown) => console.error("Failed to load preferences", error), ); diff --git a/frontend/src/app/state.ts b/frontend/src/app/state.ts index 897c8cd2d0..f9f4808cb6 100644 --- a/frontend/src/app/state.ts +++ b/frontend/src/app/state.ts @@ -4,16 +4,6 @@ import { createStore, type SetStoreFunction } from "solid-js/store"; import { createAppOwned } from "./application"; import { createAccountOwned } from "./account-scope"; -/** Preserve generic/overloaded signatures while resolving a method at call time. */ -export function appMethod unknown>( - read: () => T, -): T { - return new Proxy((() => undefined) as unknown as T, { - apply: (_target, receiver, args) => - Reflect.apply(read(), receiver, args) as unknown, - }); -} - /** Stable compatibility accessors; the value itself belongs to the mounted app. */ export function createAppSignal(): Signal; export function createAppSignal( diff --git a/frontend/src/app/theme-bootstrap.ts b/frontend/src/app/theme-bootstrap.ts index 05bbd9c6e5..b9cdff9c26 100644 --- a/frontend/src/app/theme-bootstrap.ts +++ b/frontend/src/app/theme-bootstrap.ts @@ -1,5 +1,5 @@ -import { defaultThemes } from "@oxytype/typing-core/config/default-config"; -import { themes } from "@oxytype/typing-core/themes"; +import { defaultThemes } from "@oxytype/web-shared/config/default-config"; +import { themes } from "@oxytype/web-shared/themes"; import { DEFAULT_THEME_KEY } from "./default-theme"; import { toThemeVars } from "./theme-vars"; diff --git a/frontend/src/app/theme-vars.ts b/frontend/src/app/theme-vars.ts index d79b670e31..e10956007b 100644 --- a/frontend/src/app/theme-vars.ts +++ b/frontend/src/app/theme-vars.ts @@ -1,4 +1,4 @@ -import type { Theme } from "@oxytype/typing-core/themes"; +import type { Theme } from "@oxytype/web-shared/themes"; /** Theme colors keyed by CSS custom property name, without the leading `--`. */ export function toThemeVars(colors: Theme): Record { diff --git a/frontend/src/features/settings/sidebar-skeleton.tsx b/frontend/src/features/settings/sidebar-skeleton.tsx index 8d0b4088d0..848bc5ca74 100644 --- a/frontend/src/features/settings/sidebar-skeleton.tsx +++ b/frontend/src/features/settings/sidebar-skeleton.tsx @@ -1,4 +1,4 @@ -import { getDefaultConfig } from "@oxytype/typing-core/config/default-config"; +import { getDefaultConfig } from "@oxytype/web-shared/config/default-config"; import { For, type JSXElement, Show } from "solid-js"; import type { SettingsSection } from "./tabs"; diff --git a/frontend/src/features/typing/analysis.client.ts b/frontend/src/features/typing/analysis.client.ts new file mode 100644 index 0000000000..cd9425d838 --- /dev/null +++ b/frontend/src/features/typing/analysis.client.ts @@ -0,0 +1,79 @@ +import { createAppOwned } from "../../app/application"; +import { getBrowserEngine } from "./engine.client"; +import { decodeText, toEngineLog } from "@oxytype/typing-engine/text"; +import type { WebLog } from "@oxytype/typing-engine/text"; +import type { Query, Response } from "@oxytype/typing-engine/browser.client"; + +type Stats = Extract["stats"]; +type Inspection = Extract["inspection"]; +/** Completed logs are immutable. Cache derived views per app without retaining logs. */ +const analysis = createAppOwned(() => { + const owner = getBrowserEngine(); + const results = new WeakMap(); + const inspections = new WeakMap(); + return { + query: (log: WebLog, query: Q) => + owner.api().analyze(toEngineLog(log), query), + stats: (log: WebLog): Stats => { + let value = results.get(log); + if (value === undefined) { + value = owner.api().analyze(toEngineLog(log), { + op: "stats", + now: performance.now(), + }).stats; + results.set(log, value); + } + return value; + }, + inspect: (log: WebLog): Inspection => { + let value = inspections.get(log); + if (value === undefined) { + value = owner + .api() + .analyze(toEngineLog(log), { op: "inspect" }).inspection; + inspections.set(log, value); + } + return value; + }, + }; +}); +export const analyze = ( + log: WebLog, + query: Q, +): Extract => analysis().query(log, query); +export const getAnalysis = (log: WebLog): Stats => analysis().stats(log); +export const getInspection = (log: WebLog): Inspection => + analysis().inspect(log); +export const getInputHistory = (log: WebLog): string[] => + getAnalysis(log).inputHistory.map(decodeText); +export const getCorrectedWordsHistory = (log: WebLog): string[] => + getAnalysis(log).correctedWordsHistory.map(decodeText); +// Positive infinity is represented by null in JSON; never used for submitted scores. +export const getWordBurstHistory = (log: WebLog): number[] => + getAnalysis(log).wordBurstHistory.map((value) => value ?? Infinity); +export const getMissedWords = (log: WebLog): Record => + Object.fromEntries( + getAnalysis(log).missedWords.map(([word, count]) => [ + decodeText(word), + count, + ]), + ); +export const getRawHistory = (log: WebLog): number[] => + getAnalysis(log).rawHistory; +export const getWpmHistory = (log: WebLog): number[] => + getAnalysis(log).summary.chartData.wpm; +export const getWordIndexesForSecond = ( + log: WebLog, + second: number, +): number[] => getAnalysis(log).wordIndexesPerSecond[second] ?? []; +export const getTimerBoundaryLabels = ( + log: WebLog, + showLag = true, +): string[] => + showLag ? getAnalysis(log).boundaryLabels : getInspection(log).boundaryLabels; +export const getAccuracy = (log: WebLog): Inspection["accuracy"] => + getInspection(log).accuracy; +export const getKeypressDurations = (log: WebLog): number[] => + getInspection(log).keyDuration; +export const getKeypressSpacing = (log: WebLog): number[] => + getInspection(log).keySpacing; diff --git a/frontend/src/features/typing/components/EndTestButton.tsx b/frontend/src/features/typing/components/EndTestButton.tsx index 1b0e52a915..b23138fabc 100644 --- a/frontend/src/features/typing/components/EndTestButton.tsx +++ b/frontend/src/features/typing/components/EndTestButton.tsx @@ -22,13 +22,13 @@ export function EndTestButton(): JSXElement { isTestActive() && getConfig.mode !== "zen" && getCustomTextIndicator()?.isLong !== true && - !canQuickRestart( + canQuickRestart( getConfig.mode, getConfig.words, getConfig.time, CustomText.getData(), false, - ); + ) === false; createEffectOn(canBailOut, (available) => { if (available) return; diff --git a/frontend/src/features/typing/components/TestPage.tsx b/frontend/src/features/typing/components/TestPage.tsx index 676c64f7c1..f7b9e1fe2c 100644 --- a/frontend/src/features/typing/components/TestPage.tsx +++ b/frontend/src/features/typing/components/TestPage.tsx @@ -4,6 +4,7 @@ import { spread } from "solid-js/web"; import { useWordsInputScroll } from "../../../ts/hooks/useWordsInputScroll"; import { isInputElementFocused } from "../../../ts/input/input-element"; import { isWordsWrapperVisible } from "../../../ts/states/funbox"; +import { getTestInitError } from "../../../ts/states/test"; import { setTestElements } from "../../../ts/states/test-dom"; import { getWordsWrapperHeight, @@ -11,6 +12,7 @@ import { } from "../../../ts/states/words-layout"; import { cn } from "../../../ts/utils/cn"; import { isCoarsePointer } from "../../../ts/utils/pointer"; +import { getBrowserEngine } from "../engine.client"; import { focusWords } from "../runtime/test-ui"; import { CapsWarning } from "./CapsWarning"; import { CompositionDisplay } from "./CompositionDisplay"; @@ -112,6 +114,10 @@ export function TestPage(