diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/DEVICE_VERIFICATION.md b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/DEVICE_VERIFICATION.md index 6ad4af2..c2e9d96 100644 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/DEVICE_VERIFICATION.md +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/DEVICE_VERIFICATION.md @@ -214,12 +214,20 @@ eyeball; photograph failures. | 8.5 | Advanced placeholders (image/divider nodes) | Fills/borders/labels drawn once per node; divider is one centered line | | 8.6 | Rapid typing for 30 seconds in a ~200-line document | Frame pacing stays smooth; no visible full-document flicker between keystrokes (layout cache: unchanged content redraws from the cached tree; this is the perf smoke check) | -## 9. Persistence round trip (optional but cheap) +## 9. Boot content and persistence round trip + +Since the Android empty-doc fix, saves live in the platform app-data +store (`app_data_dir()/nigig_build_store/generated/current.doc.json`) +— NOT the source tree (dev checkouts get a one-way read fallback for +old saves; new writes never go there). Boot emits `[DOC_TRACE] CRDT +init:` lines in logcat naming the branch that fired. | # | Action | Expected | |---|--------|----------| -| 9.1 | Edit, force-close, relaunch | Document restores (platform save/load path); table contents AND cell text intact | +| 9.0 | Fresh install, first launch (no save on device) | Demo document renders: bold title, styled paragraphs, divider, image placeholder, 4x3 table (bold header), closing hint. logcat: `CRDT init: no saved document; seeding demo document` | +| 9.1 | Edit, force-close, relaunch | Document restores (platform save/load path); table contents AND cell text intact. logcat: `CRDT init: loading saved document (N bytes)` | | 9.2 | Open a previously saved file with a table | Grid renders; merges present; no phantom rows/cols | +| 9.3 | Boot the CRDT workspace with a CLASSIC-format save present | Demo document seeds (classic saves are not shadowed); logcat notes the classic-format branch. The legacy workspace still opens the classic file on Open. FAIL criteria: blank page, or the classic file silently dropped | ## 10. Sign-off diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/README.md b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/README.md index 41e6a1a..8e826a6 100644 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/README.md +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/README.md @@ -1446,3 +1446,55 @@ suite covers the logic behind each row, so the runbook deliberately re-verifies only the platform-owned residuals. No code changes in this milestone beyond documentation; the roadmap box gains a pointer to the runbook for the hardware session. + +## Boot-time document init and app-data persistence (Android empty-doc fix) + +The first hardware run of the roadmap surface exposed a compound +defect no sandbox gate could see: the Android APK (`pageflipnav`) +booted the doc workspace to a BLANK page. Two independent causes: + +1. **The CRDT editor had no boot init.** The legacy `DocEditor` seeds + the showcase document behind an `initialized` flag on first draw, + but `CrdtDocEditor` — the ACTIVE editor since the navigation switch + — starts from `DocumentController::default()` (an empty projection) + and only ever gained content through an interactive action a fresh + install has not performed yet. +2. **Persistence pointed at the build machine's source tree.** + `persistence.rs` resolved its save file under + `env!("CARGO_MANIFEST_DIR")`, an absolute path baked in at compile + time. On device that path does not exist, so Open silently read + nothing and Save silently wrote nowhere (`.ok()` swallowed the + failure); on a developer machine the app polluted its own checkout. + +The fix mirrors the legacy boot contract exactly once, inside the +editor: the first event handled by a factory-fresh editor runs +`init_document`, which loads the on-disk save when it decodes as +`#MP_CRDT_V1` wire (`initial_document_source` is the gate — classic- +format saves belong to the legacy workspace's first-edit migration and +must not be shadowed) and otherwise calls `seed_demo_doc`, a CRDT +mirror of the legacy `demo_doc_blocks()` showcase: styled headings, +accent runs, a divider, an image node, the 4x3 table with a bold +header, and the closing hint. `set_engine` flips the same flag, so a +host that installs its own document before the first event is never +overwritten by the seed (this also keeps every runtime test harness +deterministic). + +Persistence migrates to the crate-wide convention +(`crate::dir::app_data_dir()`, the root the CAD store already uses): +writes go ONLY to `nigig_build_store/generated/current.doc.json` +there, while reads keep a one-way fallback to the legacy source-tree +file so an unreplicated developer save is honored once. Both the boot +and the migration emit `[DOC_TRACE]` lines (mirroring the legacy +boot's instrumentation) so a device `logcat` session confirms which +branch fired. + +Tests pin the whole contract: the boot-source gate (valid CRDT wire +boots verbatim; classic JSON and `None` both route to the demo seed), +a runtime boot test (first event on a factory-fresh editor flips the +flag and leaves a non-empty projection, source-agnostic by design), a +no-overwrite guard for host-installed engines, a full structural +assertion of the seeded showcase (heading runs, node kinds in order, +the 4x3/12-cell table with bold header), and four persistence tests +over temp dirs covering the round trip, store-beats-manifest +precedence, the manifest fallback, and empty-file rejection. +`DEVICE_VERIFICATION.md` section 9 gained the matching hardware rows. diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/crdt_widget.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/crdt_widget.rs index 9bfce34..9a659a2 100644 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/crdt_widget.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/crdt_widget.rs @@ -1,3 +1,4 @@ +use crate::construction_frame::pages::workspace::doc::persistence::load_saved_doc_state; use makepad_widgets::makepad_platform::event::TouchState; use makepad_widgets::*; use std::cell::RefCell; @@ -22,6 +23,7 @@ use crate::construction_frame::pages::workspace::doc::{ InteractionMode, MobileGestureAction, MobileGestureRouter, MobileGestureState, }; use doc_engine::controller::DocumentController; +use doc_engine::projection::TextStylePatch; /// The mobile clipboard-menu request the widget hands to the platform /// after a long-press selection (Edit mode), mirrored on the widget so @@ -94,6 +96,14 @@ pub struct CrdtDocEditor { /// actually touched. #[rust] pub mobile_mode_initialized: bool, + /// Boot flag: the first handled event seeds the document — the saved + /// on-disk state when one exists and decodes as CRDT wire, otherwise + /// the demo document (mirroring the legacy `DocEditor::initialized` + /// boot so a fresh install, most visibly a mobile APK, never opens an + /// empty page). `set_engine` also sets it: a host that installs its + /// own document before the first event keeps it untouched. + #[rust] + pub initialized: bool, /// Latest mobile clipboard-menu request; `None` until a long-press /// selection in Edit mode requests one (View mode stays passive). #[rust] @@ -117,6 +127,7 @@ impl CrdtDocEditor { pub fn set_engine(&mut self, cx: &mut Cx, engine: doc_engine::controller::DocumentController) { self.engine = engine; self.layout_cache.borrow_mut().take(); + self.initialized = true; self.redraw(cx); } @@ -293,6 +304,304 @@ impl CrdtDocEditor { true } + /// One-time boot content, mirroring the legacy editor's `initialized` + /// gate: load the saved document when it decodes as CRDT wire + /// (classic-format saves stay with the legacy workspace, which owns + /// their first-edit migration), otherwise seed the demo document so + /// the page never opens blank. No-op when the projection already + /// carries content (a host installed an engine before the first + /// event, or open/save has already run). + fn init_document(&mut self, cx: &mut Cx) { + if !self.engine.projection.blocks.is_empty() { + return; + } + let saved = load_saved_doc_state(); + let had_classic_save = saved.is_some(); + match initial_document_source(saved) { + Some(saved) => { + eprintln!( + "[DOC_TRACE] CRDT init: loading saved document ({} bytes)", + saved.len() + ); + self.deserialize(cx, &saved); + } + None => { + eprintln!( + "[DOC_TRACE] CRDT init: {}; seeding demo document", + if had_classic_save { + "saved document is classic-format (open it in the legacy workspace)" + } else { + "no saved document" + } + ); + self.seed_demo_doc(cx); + } + } + } + + /// Seeds the CRDT mirror of the legacy `demo_doc_blocks()` showcase + /// (heading, styled paragraphs, divider, image node, 4x3 table, and + /// a closing hint), chained through `after` anchors into one + /// top-level document order so layout mirrors the legacy page. + pub(crate) fn seed_demo_doc(&mut self, cx: &mut Cx) { + const ACTOR: &str = "local"; + let heading_patch = |font_size: f32, color: (f32, f32, f32, f32)| TextStylePatch { + bold: Some(true), + font_size: Some(font_size), + color: Some(Some(color)), + ..Default::default() + }; + let title_color = (0.12, 0.15, 0.35, 1.0); + let section_color = (0.18, 0.25, 0.45, 1.0); + + // 1. Title heading. + let title = "Makepad Rich Text & Document Engineering Specification"; + let title_block = self + .engine + .insert_block(ACTOR, None, "heading") + .expect("demo title block"); + self.engine + .insert_text(ACTOR, title_block.clone(), None, title); + self.engine.set_text_style_at_offsets( + ACTOR, + title_block.clone(), + 0, + title.chars().count(), + heading_patch(24.0, title_color), + ); + + // 2. Lead paragraph with a bold red accent run. + let lead_plain = "This rich document engine avoids standard "; + let lead_accent = "TextInput widgets "; + let lead_rest = "and instead implements an advanced custom widget layout engine. It \ + calculates exact glyph metrics, word-wrapping spans across line \ + boundaries, drawing custom blinking carets, and mapping character \ + offsets directly to style spans."; + let lead_block = self + .engine + .insert_block(ACTOR, Some(title_block.clone()), "paragraph") + .expect("demo lead block"); + let lead_len = + lead_plain.chars().count() + lead_accent.chars().count() + lead_rest.chars().count(); + self.engine.insert_text( + ACTOR, + lead_block.clone(), + None, + format!("{lead_plain}{lead_accent}{lead_rest}"), + ); + self.engine.set_text_style_at_offsets( + ACTOR, + lead_block.clone(), + 0, + lead_len, + TextStylePatch { + font_size: Some(13.0), + ..Default::default() + }, + ); + self.engine.set_text_style_at_offsets( + ACTOR, + lead_block.clone(), + lead_plain.chars().count(), + lead_plain.chars().count() + lead_accent.chars().count(), + TextStylePatch { + bold: Some(true), + color: Some(Some((0.8, 0.2, 0.2, 1.0))), + ..Default::default() + }, + ); + + // 3. Divider node. + let divider = self + .engine + .insert_node(ACTOR, None, Some(lead_block.clone()), "divider", "") + .expect("demo divider node"); + + // 4. First section heading. + let section_one = "1. Scenegraph & Immediate Pass Compositing"; + let section_one_block = self + .engine + .insert_block(ACTOR, Some(divider.clone()), "heading") + .expect("demo section-one block"); + self.engine + .insert_text(ACTOR, section_one_block.clone(), None, section_one); + self.engine.set_text_style_at_offsets( + ACTOR, + section_one_block.clone(), + 0, + section_one.chars().count(), + heading_patch(18.0, section_color), + ); + + // 5. Body paragraph with a bold-italic run. + let body_plain_a = + "As noted in Makepad's architecture, renderpasses can be started and ended "; + let body_accent = "nestedly without flattening the execution flow. "; + let body_plain_b = "This allows creating composite trees in the same immediate mode flow \ + as drawing your UI. For instance, font rendering is a pass that \ + repaints incrementally, keeping static textures cached on hardware \ + like the Raspberry Pi 4."; + let body_block = self + .engine + .insert_block(ACTOR, Some(section_one_block.clone()), "paragraph") + .expect("demo body block"); + let body_len = body_plain_a.chars().count() + + body_accent.chars().count() + + body_plain_b.chars().count(); + self.engine.insert_text( + ACTOR, + body_block.clone(), + None, + format!("{body_plain_a}{body_accent}{body_plain_b}"), + ); + self.engine.set_text_style_at_offsets( + ACTOR, + body_block.clone(), + 0, + body_len, + TextStylePatch { + font_size: Some(13.0), + ..Default::default() + }, + ); + self.engine.set_text_style_at_offsets( + ACTOR, + body_block.clone(), + body_plain_a.chars().count(), + body_plain_a.chars().count() + body_accent.chars().count(), + TextStylePatch { + bold: Some(true), + italic: Some(true), + ..Default::default() + }, + ); + + // 6. Image node (state carries the caption, matching the widget's + // `insert_image` encoding). + let image = self + .engine + .insert_node( + ACTOR, + None, + Some(body_block.clone()), + "image", + "Render-Target Graph vs Scenegraph Incremental Repainting", + ) + .expect("demo image node"); + + // 7. Second section heading. + let section_two = "2. Inline Interactive Table Example"; + let section_two_block = self + .engine + .insert_block(ACTOR, Some(image.clone()), "heading") + .expect("demo section-two block"); + self.engine + .insert_text(ACTOR, section_two_block.clone(), None, section_two); + self.engine.set_text_style_at_offsets( + ACTOR, + section_two_block.clone(), + 0, + section_two.chars().count(), + heading_patch(18.0, section_color), + ); + + // 8. The interactive 4x3 table (4 rows x 3 columns), header row in + // bold like the legacy demo. + let table = self + .engine + .insert_table(ACTOR, Some(section_two_block.clone())) + .expect("demo table block"); + let mut rows = Vec::new(); + for _ in 0..4 { + rows.push( + self.engine + .insert_table_row(ACTOR, table.clone(), rows.last().cloned()) + .expect("demo table row"), + ); + } + let mut columns = Vec::new(); + for _ in 0..3 { + columns.push( + self.engine + .insert_table_column(ACTOR, table.clone(), columns.last().cloned()) + .expect("demo table column"), + ); + } + let cells = [ + [ + "Feature Component", + "Implementation Technique", + "Performance Impact", + ], + [ + "Text Engine", + "Instance-packed clipping & glyph positioning", + "10,000+ items at 60 FPS", + ], + [ + "Spreadsheet Grid", + "Virtual viewport + turtle drawpass", + "O(visible cells) only", + ], + [ + "Doc Editor Table", + "Inline block layout & cell cursor mapping", + "Seamless multi-block edit", + ], + ]; + let writes: Vec<(doc_engine::crdt::OpId, doc_engine::crdt::OpId, String)> = rows + .iter() + .zip(cells.iter()) + .flat_map(|(row, texts)| { + columns + .iter() + .zip(texts.iter()) + .map(|(column, text)| (row.clone(), column.clone(), text.to_string())) + }) + .collect(); + self.engine.set_table_cells(ACTOR, table.clone(), writes); + for (column, text) in columns.iter().zip(cells[0].iter()) { + self.engine.set_table_cell_style( + ACTOR, + doc_engine::controller::TableCellRef { + table: table.clone(), + row: rows[0].clone(), + column: column.clone(), + }, + 0, + text.chars().count(), + TextStylePatch { + bold: Some(true), + ..Default::default() + }, + ); + } + + // 9. Closing hint paragraph. + let closing = "Click anywhere on the text above or inside any table cell to test live \ + caret placement and typing support!"; + let closing_block = self + .engine + .insert_block(ACTOR, Some(table.clone()), "paragraph") + .expect("demo closing block"); + self.engine + .insert_text(ACTOR, closing_block.clone(), None, closing); + self.engine.set_text_style_at_offsets( + ACTOR, + closing_block.clone(), + 0, + closing.chars().count(), + TextStylePatch { + italic: Some(true), + font_size: Some(12.0), + color: Some(Some((0.4, 0.45, 0.55, 1.0))), + ..Default::default() + }, + ); + + self.redraw(cx); + } + /// The caret atom may have been consumed by undo/redo or a merge; /// re-anchor caret and block on the closest live glyph. fn sanitize_cursor(&mut self) { @@ -1939,8 +2248,25 @@ impl CrdtDocEditor { } } +/// Decides what a fresh `CrdtDocEditor` boots with: the on-disk save when +/// it decodes as `#MP_CRDT_V1` wire, otherwise nothing (the caller then +/// seeds the demo document). Classic-format saves return `None` here so +/// the demo page never shadows them — the legacy workspace still owns +/// their first-edit migration. +pub(crate) fn initial_document_source(saved: Option) -> Option { + saved.and_then(|saved| crdt_engine_from_saved(&saved).map(|_| saved)) +} + impl Widget for CrdtDocEditor { fn handle_event(&mut self, cx: &mut Cx, event: &Event, _scope: &mut Scope) { + // Boot init runs once, before any event acts on the (factory-empty) + // projection: a fresh install otherwise renders a blank page — the + // exact failure seen on Android, where the CRDT editor starts with + // no document and no user action has seeded one yet. + if !self.initialized { + self.initialized = true; + self.init_document(cx); + } // The first real touch sequence adopts the mobile interaction // policy (mirroring the legacy editor): View mode keeps taps and // long-press/handle gestures but closes the IME and gates the diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/persistence.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/persistence.rs index 985fcf7..ec72b39 100644 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/persistence.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/persistence.rs @@ -1,42 +1,74 @@ use std::fs; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; const GENERATED_DIR: &str = "generated"; -const GENERATED_DOC_FILE: &str = "current.doc.json"; +pub(crate) const GENERATED_DOC_FILE: &str = "current.doc.json"; pub(crate) const MAX_UNDO_LEVELS: usize = 100; -fn doc_manifest_path() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf() +/// Root directory for doc state written at runtime. +/// +/// This used to be `env!("CARGO_MANIFEST_DIR")`, which bakes the **build +/// machine's** absolute source path into the shipped binary. On an Android +/// or iOS install that path does not exist, so the workspace's Open/Save +/// buttons silently did nothing (the CRDT editor additionally had no demo +/// fallback, so a fresh install booted to an empty document); and on a +/// developer machine the app wrote into its own source tree. +/// +/// `app_data_dir()` is the convention the rest of this crate already uses +/// (see `cad_store::cad_projects_dir` / `cad_persistence::cad_data_dir`). +/// Reads keep a one-way compatibility fallback to the old source-tree +/// location so existing developer saves are honored once; new saves only +/// ever go to the app data dir. +pub fn doc_store_dir() -> PathBuf { + crate::dir::app_data_dir().join("nigig_build_store") } -fn doc_generated_dir_path() -> PathBuf { - doc_manifest_path().join(GENERATED_DIR) +/// Directory holding the runtime-saved document (`nigig_build_store/generated`). +pub fn doc_generated_dir_path() -> PathBuf { + doc_store_dir().join(GENERATED_DIR) } -fn doc_generated_file_path() -> PathBuf { - doc_generated_dir_path().join(GENERATED_DOC_FILE) +/// The legacy save location inside the source tree (development checkouts +/// only): read as a fallback, never written. +fn dev_manifest_generated_dir_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GENERATED_DIR) } +/// Load the last-saved document. Prefers the runtime store; falls back to +/// the legacy source-tree file for unreplicated developer saves. Returns +/// `None` when nothing exists or the file is empty. Split from the two +/// directory parameters so the migration logic is unit-testable against +/// temp dirs. pub fn load_saved_doc_state() -> Option { - fs::read_to_string(doc_generated_file_path()) - .ok() - .filter(|source| !source.trim().is_empty()) + load_saved_doc_state_with(doc_generated_dir_path(), dev_manifest_generated_dir_path()) } +pub(crate) fn load_saved_doc_state_with( + store_dir: PathBuf, + manifest_dir: PathBuf, +) -> Option { + let read = |dir: &PathBuf| { + fs::read_to_string(dir.join(GENERATED_DOC_FILE)) + .ok() + .filter(|source| !source.trim().is_empty()) + }; + read(&store_dir).or_else(|| read(&manifest_dir)) +} + +/// Save the current document to the runtime store (never the source tree). pub fn save_doc_state(data: &str) -> Result<(), String> { - let dir = doc_generated_dir_path(); - fs::create_dir_all(&dir) - .map_err(|err| format!("could not create generated directory: {err}"))?; - fs::write(dir.join(GENERATED_DOC_FILE), data) - .map_err(|err| format!("could not save doc state: {err}"))?; - Ok(()) + save_doc_state_to(doc_generated_dir_path(), GENERATED_DOC_FILE, data) } +/// Save a copy under a caller-chosen filename in the runtime store. pub fn save_doc_state_as(filename: &str, data: &str) -> Result<(), String> { - let dir = doc_generated_dir_path(); + save_doc_state_to(doc_generated_dir_path(), filename, data) +} + +pub(crate) fn save_doc_state_to(dir: PathBuf, filename: &str, data: &str) -> Result<(), String> { fs::create_dir_all(&dir) .map_err(|err| format!("could not create generated directory: {err}"))?; fs::write(dir.join(filename), data) - .map_err(|err| format!("could not save doc state as {filename}: {err}"))?; + .map_err(|err| format!("could not save doc state: {err}"))?; Ok(()) } diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/tests.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/tests.rs index 49a001a..b0d7303 100644 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/tests.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/tests.rs @@ -5226,3 +5226,233 @@ fn layout_tree_cache_drops_on_engine_replacement() { "the new document caches normally afterwards" ); } + +// == Boot-time document init + persistence store (Android empty-doc fix) === +// +// The shipped APK booted to a blank doc workspace for two compounding +// reasons: the CRDT editor (the active one) had no boot init — only the +// legacy editor's `initialized` gate seeded the demo document — and +// persistence read from the build machine's absolute source-tree path, +// which does not exist on device. These tests pin the replacement +// behaviour: a one-time boot seed from the app-data store (CRDT wire +// only) falling back to the demo document, plus the store/manifest read +// precedence of the migrated persistence layer. + +use super::crdt_widget::initial_document_source; +use super::persistence::{load_saved_doc_state_with, save_doc_state_to, GENERATED_DOC_FILE}; + +/// A factory-fresh editor with NO engine installed: the exact boot state +/// of the APK that opened blank. +fn crdt_editor_default() -> (Cx, CrdtDocEditor) { + let mut cx = Cx::new(Box::new(|_, _| {})); + let editor = { + let mut host = (); + let mut std = (); + let mut vm = ScriptVm { + host: &mut host, + std: &mut std, + bx: Box::new(ScriptVmBase::new()), + }; + CrdtDocEditor::script_new(&mut vm) + }; + (cx, editor) +} + +#[test] +fn boot_source_prefers_crdt_save_and_rejects_classic_format() { + assert_eq!(initial_document_source(None), None); + // A classic-format save is neither loaded by the CRDT editor nor + // mistaken for a CRDT save: the demo document boots instead, and the + // legacy workspace keeps owning the classic file on Open. + let classic = "{\"blocks\":[{\"kind\":\"paragraph\"}]}".to_string(); + assert_eq!(initial_document_source(Some(classic)), None); + // A valid CRDT save boots verbatim. + let mut engine = CrdtController::default(); + let block = engine + .insert_block("local", None, "paragraph") + .expect("block"); + engine.insert_text("local", block, None, "saved boot text"); + let wire = crdt_save_wire(&engine.document).expect("wire"); + assert_eq!(initial_document_source(Some(wire.clone())), Some(wire)); +} + +#[test] +fn fresh_editor_boots_document_on_first_event() { + let (mut cx, mut editor) = crdt_editor_default(); + assert!(editor.engine().projection.blocks.is_empty()); + assert!(!editor.initialized); + editor.handle_event( + &mut cx, + &runtime_key_down(KeyCode::Escape), + &mut Scope::empty(), + ); + assert!(editor.initialized); + // Source-agnostic (a developer machine may have a real save on disk): + // whichever of save/demo won, the page is not blank. + assert!( + !editor.engine().projection.blocks.is_empty(), + "boot init must leave a document in the projection" + ); +} + +#[test] +fn host_installed_engine_is_not_overwritten_by_boot_seed() { + // `set_engine` marks the widget initialized: a host that installs its + // own document (every runtime test above included) must never get + // demo content or a disk save injected on the first event. + let (mut cx, mut editor) = crdt_editor_with_engine(CrdtController::default()); + assert!(editor.initialized); + editor.handle_event( + &mut cx, + &runtime_key_down(KeyCode::Escape), + &mut Scope::empty(), + ); + assert!( + editor.engine().projection.blocks.is_empty(), + "boot seed must not touch a document the host already installed" + ); +} + +#[test] +fn seed_demo_doc_builds_showcase_document() { + let (mut cx, mut editor) = crdt_editor_default(); + editor.seed_demo_doc(&mut cx); + let projection = &editor.engine().projection; + + let visible_text: Vec<&str> = projection + .blocks + .iter() + .map(|block| block.text.as_str()) + .collect(); + let title = projection + .blocks + .iter() + .find(|block| { + block + .text + .contains("Makepad Rich Text & Document Engineering Specification") + }) + .expect("title block"); + assert!( + title + .runs + .iter() + .any(|run| run.bold && run.font_size >= 24.0), + "title carries the bold 24pt heading run" + ); + assert!(visible_text + .iter() + .any(|text| text.contains("Click anywhere on the text above or inside any table cell"))); + + // One divider and one image node, in that order. + let kinds: Vec<&str> = projection + .nodes + .iter() + .map(|node| node.kind.as_str()) + .collect(); + assert_eq!(kinds, ["divider", "image"]); + + // The 4x3 demo table: 12 written cells and a bold header row. + let table = projection.tables.values().next().expect("demo table"); + assert_eq!(table.rows.len(), 4); + assert_eq!(table.columns.len(), 3); + assert_eq!( + table.cells.values().filter(|text| !text.is_empty()).count(), + 12 + ); + assert!(table + .cells + .values() + .any(|text| text == "10,000+ items at 60 FPS")); + assert_eq!( + table + .cells + .get(&(table.rows[0].clone(), table.columns[0].clone())) + .map(String::as_str), + Some("Feature Component") + ); + let header_runs = table + .cell_runs + .get(&(table.rows[0].clone(), table.columns[0].clone())) + .expect("header cell runs"); + assert!( + header_runs.iter().all(|run| run.bold), + "table header row renders bold" + ); +} + +// ---- persistence store migration -------------------------------------- + +fn temp_store_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "nigig-doc-persistence-{}-{tag}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + dir +} + +#[test] +fn saved_doc_round_trips_through_runtime_store() { + let store = temp_store_dir("round-trip"); + save_doc_state_to(store.clone(), GENERATED_DOC_FILE, "{\"doc\":\"state\"}").expect("save"); + assert_eq!( + load_saved_doc_state_with(store.clone(), store.join("no-manifest-here")), + Some("{\"doc\":\"state\"}".to_string()) + ); + let _ = std::fs::remove_dir_all(&store); +} + +#[test] +fn runtime_store_takes_precedence_over_manifest_fallback() { + let store = temp_store_dir("store"); + let manifest = temp_store_dir("manifest"); + save_doc_state_to(store.clone(), GENERATED_DOC_FILE, "store version").expect("store save"); + save_doc_state_to(manifest.clone(), GENERATED_DOC_FILE, "manifest version") + .expect("manifest save"); + assert_eq!( + load_saved_doc_state_with(store.clone(), manifest.clone()), + Some("store version".to_string()), + "a document in the app-data store wins over the legacy source-tree copy" + ); + let _ = std::fs::remove_dir_all(&store); + let _ = std::fs::remove_dir_all(&manifest); +} + +#[test] +fn manifest_fallback_serves_legacy_saves_once() { + let store = temp_store_dir("empty-store"); + let manifest = temp_store_dir("legacy-manifest"); + // No file in the runtime store: the legacy source-tree copy is still + // honored (it is read-only now — new saves never go there). + save_doc_state_to(manifest.clone(), GENERATED_DOC_FILE, "legacy save").expect("manifest save"); + assert_eq!( + load_saved_doc_state_with(store.clone(), manifest.clone()), + Some("legacy save".to_string()) + ); + let _ = std::fs::remove_dir_all(&store); + let _ = std::fs::remove_dir_all(&manifest); +} + +#[test] +fn empty_or_missing_save_files_boot_fresh() { + let store = temp_store_dir("empty-file-store"); + let manifest = temp_store_dir("missing-manifest"); + save_doc_state_to(store.clone(), GENERATED_DOC_FILE, " \n").expect("save"); + // An empty/whitespace save is treated as absent: the workspace boots + // fresh (demo seed) rather than feeding the loader a blank document. + assert_eq!( + load_saved_doc_state_with(store.clone(), manifest.clone()), + None + ); + // And with nothing anywhere there is nothing to load. + let bare = temp_store_dir("bare"); + assert_eq!( + load_saved_doc_state_with(bare.clone(), bare.join("none")), + None + ); + let _ = std::fs::remove_dir_all(&store); + let _ = std::fs::remove_dir_all(&manifest); + let _ = std::fs::remove_dir_all(&bare); +} diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/project/mod.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/project/mod.rs index 7e05224..c10e852 100644 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/project/mod.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/project/mod.rs @@ -1575,7 +1575,11 @@ impl BuildProjectsPage { .borrow_mut::() { grid.set_cell_value(cx, target_row, target_col, &formula_out); - grid.select_cell(cx, target_row, target_col); + // select_cell() was retired upstream + // (spreadsheet-ui 6a3c467); set_selection_pair is the + // replacement selection API, and `self.view.redraw` + // below covers the paint. + grid.set_selection_pair((target_row, target_col), (target_row, target_col)); } self.view .label(cx, ids!(m_ss_cell_ref)) @@ -1596,7 +1600,7 @@ impl BuildProjectsPage { .borrow_mut::() { grid.set_cell_value(cx, target.0, target.1, &formula_text); - grid.select_cell(cx, target.0, target.1); + grid.set_selection_pair((target.0, target.1), (target.0, target.1)); } self.view .label(cx, ids!(m_ss_cell_ref)) diff --git a/crates/apps/nigig-build/tests/cost_estimator.rs b/crates/apps/nigig-build/tests/cost_estimator.rs index 47575f2..cb43f4e 100644 --- a/crates/apps/nigig-build/tests/cost_estimator.rs +++ b/crates/apps/nigig-build/tests/cost_estimator.rs @@ -131,7 +131,11 @@ fn workflow_lookup_rate() { #[test] fn workflow_default_rooms() { let data = get_building_data(); - let cost_data = data.get(&BuildingCategory::Residential).unwrap().first().unwrap(); + let cost_data = data + .get(&BuildingCategory::Residential) + .unwrap() + .first() + .unwrap(); let default = get_default_rooms(cost_data, "Standard Bungalow"); assert!( default.is_some(), diff --git a/crates/apps/nigig-build/tests/ui.rs b/crates/apps/nigig-build/tests/ui.rs index 88b09ff..9329bf6 100644 --- a/crates/apps/nigig-build/tests/ui.rs +++ b/crates/apps/nigig-build/tests/ui.rs @@ -792,10 +792,13 @@ fn cad_toolbar_buttons_exist(app: TestApp) { app.locator(Selector::id("beam_tool_btn")).wait_visible(); app.locator(Selector::id("column_tool_btn")).wait_visible(); app.locator(Selector::id("add_cube_button")).wait_visible(); - app.locator(Selector::id("add_cylinder_button")).wait_visible(); - app.locator(Selector::id("add_sphere_button")).wait_visible(); + app.locator(Selector::id("add_cylinder_button")) + .wait_visible(); + app.locator(Selector::id("add_sphere_button")) + .wait_visible(); app.locator(Selector::id("add_door_button")).wait_visible(); - app.locator(Selector::id("add_window_button")).wait_visible(); + app.locator(Selector::id("add_window_button")) + .wait_visible(); } #[makepad_test] @@ -847,7 +850,8 @@ fn cad_toolbar_snap_ortho_polar_buttons_exist(app: TestApp) { app.locator(Selector::id("snap_toggle_btn")).wait_visible(); app.locator(Selector::id("ortho_toggle_btn")).wait_visible(); app.locator(Selector::id("polar_toggle_btn")).wait_visible(); - app.locator(Selector::id("snap_step_dropdown")).wait_visible(); + app.locator(Selector::id("snap_step_dropdown")) + .wait_visible(); } #[makepad_test] @@ -870,8 +874,10 @@ fn cad_toolbar_visibility_buttons_exist(app: TestApp) { } app.locator(Selector::id("cad_viewport")).wait_visible(); app.locator(Selector::id("clip_toggle_btn")).wait_visible(); - app.locator(Selector::id("constr_toggle_btn")).wait_visible(); - app.locator(Selector::id("constr_export_btn")).wait_visible(); + app.locator(Selector::id("constr_toggle_btn")) + .wait_visible(); + app.locator(Selector::id("constr_export_btn")) + .wait_visible(); } // ── Tool selection tests ────────────────────────────────────── @@ -882,7 +888,8 @@ fn cad_select_tool_default(app: TestApp) { return; } app.locator(Selector::id("cad_viewport")).wait_visible(); - app.locator(Selector::id("select_tool_btn")).wait_text("Sel"); + app.locator(Selector::id("select_tool_btn")) + .wait_text("Sel"); } #[makepad_test] @@ -918,7 +925,8 @@ fn cad_tool_key_switches_via_button_text(app: TestApp) { app.locator(Selector::id("cad_viewport")).click(); app.press_key(makepad_test::KeyCode::KeyC); // Verify button text changed to "Cir" - app.locator(Selector::id("circle_tool_btn")).wait_text("Cir"); + app.locator(Selector::id("circle_tool_btn")) + .wait_text("Cir"); } // ── Drawing creation tests ───────────────────────────────────── @@ -947,7 +955,8 @@ fn cad_draw_circle_with_radius(app: TestApp) { } app.locator(Selector::id("cad_viewport")).wait_visible(); app.locator(Selector::id("circle_tool_btn")).click(); - app.locator(Selector::id("status_label")).wait_text("Circle"); + app.locator(Selector::id("status_label")) + .wait_text("Circle"); // Start circle app.locator(Selector::id("cad_viewport")).click(); // Type radius value + Enter (DDE) @@ -955,7 +964,6 @@ fn cad_draw_circle_with_radius(app: TestApp) { app.press_key(makepad_test::KeyCode::ReturnKey); } - #[makepad_test] fn cad_draw_wall_segment(app: TestApp) { if !require_project_loaded() { @@ -976,13 +984,13 @@ fn cad_draw_column_with_radius(app: TestApp) { } app.locator(Selector::id("cad_viewport")).wait_visible(); app.locator(Selector::id("column_tool_btn")).click(); - app.locator(Selector::id("status_label")).wait_text("Column"); + app.locator(Selector::id("status_label")) + .wait_text("Column"); app.locator(Selector::id("cad_viewport")).click(); app.press_key(makepad_test::KeyCode::Key3); app.press_key(makepad_test::KeyCode::ReturnKey); } - #[makepad_test] fn cad_draw_beam_with_ibeam_section(app: TestApp) { if !require_project_loaded() { @@ -1035,7 +1043,8 @@ fn cad_delete_tool_removes_parts(app: TestApp) { app.locator(Selector::id("cad_viewport")).click(); // Switch to delete tool app.locator(Selector::id("delete_tool_btn")).click(); - app.locator(Selector::id("status_label")).wait_text("Delete"); + app.locator(Selector::id("status_label")) + .wait_text("Delete"); // Select and delete app.locator(Selector::id("cad_viewport")).click(); app.locator(Selector::id("cad_viewport")).wait_visible(); @@ -1052,7 +1061,8 @@ fn cad_escape_cancels_active_tool(app: TestApp) { // Cancel with Escape app.press_key(makepad_test::KeyCode::Escape); // Should return to Select - app.locator(Selector::id("status_label")).wait_text("Select"); + app.locator(Selector::id("status_label")) + .wait_text("Select"); } // ── View manipulation tests ──────────────────────────────────── @@ -1064,16 +1074,20 @@ fn cad_plane_toggle_cycles_workplanes(app: TestApp) { } app.locator(Selector::id("cad_viewport")).wait_visible(); // Initially the plane button should show "XY Plan" - app.locator(Selector::id("plane_toggle_btn")).wait_text("XY Plan"); + app.locator(Selector::id("plane_toggle_btn")) + .wait_text("XY Plan"); // Toggle to XZ app.locator(Selector::id("plane_toggle_btn")).click(); - app.locator(Selector::id("plane_toggle_btn")).wait_text("XZ Front"); + app.locator(Selector::id("plane_toggle_btn")) + .wait_text("XZ Front"); // Toggle to YZ app.locator(Selector::id("plane_toggle_btn")).click(); - app.locator(Selector::id("plane_toggle_btn")).wait_text("YZ Side"); + app.locator(Selector::id("plane_toggle_btn")) + .wait_text("YZ Side"); // Toggle back to XY app.locator(Selector::id("plane_toggle_btn")).click(); - app.locator(Selector::id("plane_toggle_btn")).wait_text("XY Plan"); + app.locator(Selector::id("plane_toggle_btn")) + .wait_text("XY Plan"); } #[makepad_test] @@ -1084,15 +1098,20 @@ fn cad_workplane_rotation_cycle(app: TestApp) { app.locator(Selector::id("cad_viewport")).wait_visible(); // Rot WP cycles: 0 -> 15 -> 30 -> 45 -> 90 -> -45 -> 0 app.locator(Selector::id("rot_wp_btn")).click(); - app.locator(Selector::id("rot_wp_btn")).wait_text("XY Plan 15°"); + app.locator(Selector::id("rot_wp_btn")) + .wait_text("XY Plan 15°"); app.locator(Selector::id("rot_wp_btn")).click(); - app.locator(Selector::id("rot_wp_btn")).wait_text("XY Plan 30°"); + app.locator(Selector::id("rot_wp_btn")) + .wait_text("XY Plan 30°"); app.locator(Selector::id("rot_wp_btn")).click(); - app.locator(Selector::id("rot_wp_btn")).wait_text("XY Plan 45°"); + app.locator(Selector::id("rot_wp_btn")) + .wait_text("XY Plan 45°"); app.locator(Selector::id("rot_wp_btn")).click(); - app.locator(Selector::id("rot_wp_btn")).wait_text("XY Plan 90°"); + app.locator(Selector::id("rot_wp_btn")) + .wait_text("XY Plan 90°"); app.locator(Selector::id("rot_wp_btn")).click(); - app.locator(Selector::id("rot_wp_btn")).wait_text("XY Plan -45°"); + app.locator(Selector::id("rot_wp_btn")) + .wait_text("XY Plan -45°"); app.locator(Selector::id("rot_wp_btn")).click(); app.locator(Selector::id("rot_wp_btn")).wait_text("XY Plan"); } @@ -1236,7 +1255,8 @@ fn cad_render_mode_dropdown_exists(app: TestApp) { return; } app.locator(Selector::id("cad_viewport")).wait_visible(); - app.locator(Selector::id("render_mode_dropdown")).wait_visible(); + app.locator(Selector::id("render_mode_dropdown")) + .wait_visible(); } #[makepad_test] @@ -1245,7 +1265,8 @@ fn cad_view_toggle_button_exists(app: TestApp) { return; } app.locator(Selector::id("cad_viewport")).wait_visible(); - app.locator(Selector::id("view_toggle_button")).wait_visible(); + app.locator(Selector::id("view_toggle_button")) + .wait_visible(); } // ── Export tests ─────────────────────────────────────────────── @@ -1269,7 +1290,8 @@ fn cad_svg_export_switches_to_preview(app: TestApp) { app.locator(Selector::id("cad_viewport")).wait_visible(); app.locator(Selector::id("export_svg_btn")).click(); // SVG preview widget should become visible after export - app.locator(Selector::id("desktop_svg_preview")).wait_visible(); + app.locator(Selector::id("desktop_svg_preview")) + .wait_visible(); } #[makepad_test] @@ -1320,7 +1342,8 @@ fn cad_pdf_tab_button_exists(app: TestApp) { return; } app.locator(Selector::id("cad_viewport")).wait_visible(); - app.locator(Selector::id("desktop_pdf_tab_btn")).wait_visible(); + app.locator(Selector::id("desktop_pdf_tab_btn")) + .wait_visible(); } #[makepad_test] @@ -1345,7 +1368,8 @@ fn cad_pdf_tab_then_back_to_script(app: TestApp) { app.locator(Selector::id("desktop_pdf_page")).wait_visible(); // Switch back to Script app.locator(Selector::id("desktop_editor_tab_btn")).click(); - app.locator(Selector::id("desktop_script_page")).wait_visible(); + app.locator(Selector::id("desktop_script_page")) + .wait_visible(); } #[makepad_test] @@ -1355,7 +1379,8 @@ fn cad_pdf_preview_placeholder_visible(app: TestApp) { } app.locator(Selector::id("cad_viewport")).wait_visible(); app.locator(Selector::id("desktop_pdf_tab_btn")).click(); - app.locator(Selector::id("desktop_preview_placeholder")).wait_visible(); + app.locator(Selector::id("desktop_preview_placeholder")) + .wait_visible(); } #[makepad_test] @@ -1368,7 +1393,8 @@ fn cad_pdf_tab_then_cost_tab(app: TestApp) { app.locator(Selector::id("desktop_pdf_page")).wait_visible(); // Switch to Cost tab app.locator(Selector::id("desktop_cost_tab_btn")).click(); - app.locator(Selector::id("desktop_cost_page")).wait_visible(); + app.locator(Selector::id("desktop_cost_page")) + .wait_visible(); } // ── Code editor tests ────────────────────────────────────────── @@ -1379,7 +1405,8 @@ fn cad_script_editor_visible(app: TestApp) { return; } app.locator(Selector::id("cad_viewport")).wait_visible(); - app.locator(Selector::id("desktop_script_page")).wait_visible(); + app.locator(Selector::id("desktop_script_page")) + .wait_visible(); app.locator(Selector::id("cad_editor")).wait_visible(); } @@ -1389,10 +1416,14 @@ fn cad_editor_tabs_exist(app: TestApp) { return; } app.locator(Selector::id("cad_viewport")).wait_visible(); - app.locator(Selector::id("desktop_editor_tab_btn")).wait_visible(); - app.locator(Selector::id("desktop_cost_tab_btn")).wait_visible(); - app.locator(Selector::id("desktop_split_tab_btn")).wait_visible(); - app.locator(Selector::id("desktop_pdf_tab_btn")).wait_visible(); + app.locator(Selector::id("desktop_editor_tab_btn")) + .wait_visible(); + app.locator(Selector::id("desktop_cost_tab_btn")) + .wait_visible(); + app.locator(Selector::id("desktop_split_tab_btn")) + .wait_visible(); + app.locator(Selector::id("desktop_pdf_tab_btn")) + .wait_visible(); } #[makepad_test] @@ -1401,12 +1432,16 @@ fn cad_code_editor_has_content(app: TestApp) { return; } app.locator(Selector::id("cad_viewport")).wait_visible(); - app.locator(Selector::id("desktop_script_page")).wait_visible(); + app.locator(Selector::id("desktop_script_page")) + .wait_visible(); // The editor should have some initial content app.locator(Selector::id("cad_editor")).wait_visible(); app.locator(Selector::id("cad_editor")).click(); let dump = app.widget_dump(); - assert!(dump.contains("cad_editor"), "Widget dump should contain cad_editor"); + assert!( + dump.contains("cad_editor"), + "Widget dump should contain cad_editor" + ); } // ── Cost estimation screen tests ─────────────────────────────── @@ -1417,7 +1452,8 @@ fn cad_cost_tab_button_visible(app: TestApp) { return; } app.locator(Selector::id("cad_viewport")).wait_visible(); - app.locator(Selector::id("desktop_cost_tab_btn")).wait_visible(); + app.locator(Selector::id("desktop_cost_tab_btn")) + .wait_visible(); } #[makepad_test] @@ -1427,8 +1463,10 @@ fn cad_cost_page_exists(app: TestApp) { } app.locator(Selector::id("cad_viewport")).wait_visible(); app.locator(Selector::id("desktop_cost_tab_btn")).click(); - app.locator(Selector::id("desktop_cost_page")).wait_visible(); - app.locator(Selector::id("cost_estimate_screen")).wait_visible(); + app.locator(Selector::id("desktop_cost_page")) + .wait_visible(); + app.locator(Selector::id("cost_estimate_screen")) + .wait_visible(); } // ── AI pane tests ────────────────────────────────────────────── @@ -1440,7 +1478,8 @@ fn cad_ai_pane_exists(app: TestApp) { } app.locator(Selector::id("cad_viewport")).wait_visible(); app.locator(Selector::id("ai_status_label")).wait_visible(); - app.locator(Selector::id("ai_generate_button")).wait_visible(); + app.locator(Selector::id("ai_generate_button")) + .wait_visible(); app.locator(Selector::id("ai_cancel_button")).wait_visible(); app.locator(Selector::id("backend_dropdown")).wait_visible(); app.locator(Selector::id("cad_prompt_input")).wait_visible(); @@ -1485,7 +1524,8 @@ fn cad_splitter_exists(app: TestApp) { return; } app.locator(Selector::id("cad_viewport")).wait_visible(); - app.locator(Selector::id("workspace_split_toggle_btn")).wait_visible(); + app.locator(Selector::id("workspace_split_toggle_btn")) + .wait_visible(); } #[makepad_test] @@ -1494,11 +1534,14 @@ fn cad_workspace_split_toggle(app: TestApp) { return; } app.locator(Selector::id("cad_viewport")).wait_visible(); - app.locator(Selector::id("workspace_split_toggle_btn")).click(); + app.locator(Selector::id("workspace_split_toggle_btn")) + .click(); // Split viewport should appear - app.locator(Selector::id("split_viewport_layer")).wait_visible(); + app.locator(Selector::id("split_viewport_layer")) + .wait_visible(); // Toggle back - app.locator(Selector::id("workspace_split_toggle_btn")).click(); + app.locator(Selector::id("workspace_split_toggle_btn")) + .click(); app.locator(Selector::id("cad_viewport")).wait_visible(); } @@ -1510,7 +1553,8 @@ fn cad_properties_panel_exists(app: TestApp) { return; } app.locator(Selector::id("cad_viewport")).wait_visible(); - app.locator(Selector::id("properties_panel_view")).wait_visible(); + app.locator(Selector::id("properties_panel_view")) + .wait_visible(); } // ── Status label tests ─────────────────────────────────────────── @@ -1530,7 +1574,8 @@ fn cad_status_label_shows_select_initially(app: TestApp) { return; } app.locator(Selector::id("cad_viewport")).wait_visible(); - app.locator(Selector::id("status_label")).wait_text("Select"); + app.locator(Selector::id("status_label")) + .wait_text("Select"); } // ── Mobile editor tests ────────────────────────────────────────── @@ -1541,7 +1586,8 @@ fn cad_mobile_editor_tab_button_exists(app: TestApp) { return; } app.locator(Selector::id("cad_viewport")).wait_visible(); - app.locator(Selector::id("mobile_editor_tab_btn")).wait_visible(); + app.locator(Selector::id("mobile_editor_tab_btn")) + .wait_visible(); } #[makepad_test] @@ -1550,7 +1596,8 @@ fn cad_mobile_cost_tab_button_exists(app: TestApp) { return; } app.locator(Selector::id("cad_viewport")).wait_visible(); - app.locator(Selector::id("mobile_cost_tab_btn")).wait_visible(); + app.locator(Selector::id("mobile_cost_tab_btn")) + .wait_visible(); } // ── Snap step dropdown tests ──────────────────────────────────── @@ -1561,7 +1608,8 @@ fn cad_snap_step_dropdown_visible(app: TestApp) { return; } app.locator(Selector::id("cad_viewport")).wait_visible(); - app.locator(Selector::id("snap_step_dropdown")).wait_visible(); + app.locator(Selector::id("snap_step_dropdown")) + .wait_visible(); } // ── Extrude button test ────────────────────────────────────────── @@ -1584,7 +1632,8 @@ fn cad_measure_tool_button(app: TestApp) { } app.locator(Selector::id("cad_viewport")).wait_visible(); app.locator(Selector::id("measure_tool_btn")).click(); - app.locator(Selector::id("status_label")).wait_text("Measure"); + app.locator(Selector::id("status_label")) + .wait_text("Measure"); } // ── Delete tool test ───────────────────────────────────────────── @@ -1596,7 +1645,8 @@ fn cad_delete_tool_button(app: TestApp) { } app.locator(Selector::id("cad_viewport")).wait_visible(); app.locator(Selector::id("delete_tool_btn")).click(); - app.locator(Selector::id("status_label")).wait_text("Delete"); + app.locator(Selector::id("status_label")) + .wait_text("Delete"); } // ── Arc tool test ──────────────────────────────────────────────── @@ -1620,7 +1670,8 @@ fn cad_polyline_tool_button(app: TestApp) { } app.locator(Selector::id("cad_viewport")).wait_visible(); app.locator(Selector::id("polyline_tool_btn")).click(); - app.locator(Selector::id("status_label")).wait_text("Polyline"); + app.locator(Selector::id("status_label")) + .wait_text("Polyline"); } // ── Area and Quad tool tests ───────────────────────────────────── @@ -1654,7 +1705,8 @@ fn cad_polygon_tool_button(app: TestApp) { } app.locator(Selector::id("cad_viewport")).wait_visible(); app.locator(Selector::id("polygon_tool_btn")).click(); - app.locator(Selector::id("status_label")).wait_text("Polygon"); + app.locator(Selector::id("status_label")) + .wait_text("Polygon"); } #[makepad_test] @@ -1664,7 +1716,8 @@ fn cad_triplane_tool_button(app: TestApp) { } app.locator(Selector::id("cad_viewport")).wait_visible(); app.locator(Selector::id("triplane_tool_btn")).click(); - app.locator(Selector::id("status_label")).wait_text("TriPlane"); + app.locator(Selector::id("status_label")) + .wait_text("TriPlane"); } // ── Extend and Chamfer tool tests ──────────────────────────────── @@ -1676,7 +1729,8 @@ fn cad_extend_tool_button(app: TestApp) { } app.locator(Selector::id("cad_viewport")).wait_visible(); app.locator(Selector::id("extend_tool_btn")).click(); - app.locator(Selector::id("status_label")).wait_text("Extend"); + app.locator(Selector::id("status_label")) + .wait_text("Extend"); } #[makepad_test] @@ -1686,7 +1740,8 @@ fn cad_chamfer_tool_button(app: TestApp) { } app.locator(Selector::id("cad_viewport")).wait_visible(); app.locator(Selector::id("chamfer_tool_btn")).click(); - app.locator(Selector::id("status_label")).wait_text("Chamfer"); + app.locator(Selector::id("status_label")) + .wait_text("Chamfer"); } // ── Render mode test ───────────────────────────────────────────── @@ -1721,7 +1776,8 @@ fn cad_workspace_title_visible(app: TestApp) { return; } app.locator(Selector::id("cad_viewport")).wait_visible(); - app.locator(Selector::id("workspace_title_label")).wait_visible(); + app.locator(Selector::id("workspace_title_label")) + .wait_visible(); } // ── Status label text change test ──────────────────────────────── @@ -1733,7 +1789,8 @@ fn cad_status_label_changes_with_tool(app: TestApp) { } app.locator(Selector::id("cad_viewport")).wait_visible(); // Start on Select - app.locator(Selector::id("status_label")).wait_text("Select"); + app.locator(Selector::id("status_label")) + .wait_text("Select"); // Switch to Line app.locator(Selector::id("cad_viewport")).click(); app.press_key(makepad_test::KeyCode::KeyL); @@ -1766,9 +1823,13 @@ fn cad_snap_toggle_button_checkable(app: TestApp) { return; } app.locator(Selector::id("cad_viewport")).wait_visible(); - let before = app.locator(Selector::id("snap_toggle_btn")).assert_enabled(true); + let before = app + .locator(Selector::id("snap_toggle_btn")) + .assert_enabled(true); app.locator(Selector::id("snap_toggle_btn")).click(); - let after = app.locator(Selector::id("snap_toggle_btn")).assert_enabled(true); + let after = app + .locator(Selector::id("snap_toggle_btn")) + .assert_enabled(true); app.locator(Selector::id("cad_viewport")).wait_visible(); }