diff --git a/context/code/protocol.md b/context/code/protocol.md index 06d94af1..ff926088 100644 --- a/context/code/protocol.md +++ b/context/code/protocol.md @@ -258,6 +258,35 @@ client's own token is the lookup key for its own version, with no `CellKey` rebuilt on the hot path. `measure` is absent for a record carrying no `_Measure` coordinate at all — the shape `add-record-row` seeds. +### `CellId::Derived` — a formula owns the cell (improvise-ir7l) + +A formula target is a real stored coordinate, so the record branch claimed it +and every client offered an editor over it. The write *landed* and was then +shadowed forever, because `evaluate_aggregated` reads `formula_cache` before +raw data — not a revert, a permanent silent shadow. + +The fix keeps ADR 002's discipline rather than adding a second answer beside +it: a formula-owned cell projects a **different kind of identity**, so the +write is unconstructible instead of flagged. `Derived` carries the formula's +target and **no surrogate**, so there is nothing a client could spend on a +`SetCell`; that is the same reason `Aggregate` carries an `AggregateHash` and +not a `RecordHash`. + +Two orderings matter in `project::cell_id`. Derived is asked **before** the +record branch, since the coordinate is stored and the record branch would +otherwise win. It is asked **after** the aggregate branch, because a formula +cell that also aggregates is still drillable (gotcha 10) — `Aggregate` and +`Derived` are not alternatives for the same cell, and reversing the two turns +a drillable cell into a dead one. + +The predicate itself is `Model::formula_for(&CellKey)` in `improvise-core`, +the one home of "is this cell derived". It already existed three times inside +`model/types.rs` as an open-coded loop; the TUI's `EditOrDrill` and the +producer now call the same function rather than each spelling the rule. The +TUI had to change in the same breath: it re-derives from the `Model` it holds, +which is legitimate (it is the authority), but it must give the *same* answer +or the two front ends diverge — design-principles §5. + ### `CellDisplay::id` — `None` is not "aggregate" That is `Some(CellId::Aggregate(..))`, and the distinction is the whole point diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 28a6935d..8db7c189 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -665,3 +665,18 @@ persistence criterion. - **`a_delete_is_visible_at_once_and_lands_on_commit`** — both halves, because either one alone would pass against a backend that got the timing exactly backwards. + +### the_shipped_demo_seeds_a_worker_whose_raw_cells_are_settable + +improvise-ir7l renamed this from `..._whose_every_cell_is_settable` and +narrowed what it claims. The old loop called `MainThread::cell` — which panics +on anything but a stored cell — over *every* column including `Profit`, which +`web/demo.improv` defines as `Revenue - Cost`. It passed only because a formula +target projected `CellId::Record`, which is the bug the ticket names: a test +whose name was false for a third of the grid it walked. + +It now walks the two raw columns and asserts separately that the `Profit` +column is `CellId::Derived`, so it fails if either half moves. That second +assertion is what makes the narrowing honest rather than a retreat — dropping +the column would have left the page free to offer it again with nothing +noticing. diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index f6da010d..546837e1 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -2166,3 +2166,33 @@ step machine; every other printable key edits the name (gotcha 22). On the confirmation step Enter installs the built workbook and Esc steps back — it does not throw the import away, and no key closes the wizard outright (improvise-907, from improvise-c6j). + +### a_formula_owned_cell_is_projected_as_derived_and_its_siblings_are_not + +improvise-ir7l. `project::cell_id` decided `Record` vs `Aggregate` purely on +whether a regular category sat on `Axis::None`; it never asked whether the +`_Measure` coordinate named a formula target. So a formula cell was a `Record`, +`render.rs::editable` said true, and a write to it was accepted, stored and +then shadowed forever by `formula_cache`. + +The fault injected: replacing the `derived` lookup with `None`, which is the +pre-fix code exactly. The test then reports `Record(RecordCell { measure: +Some("Profit") })` where it wants `Derived { target: "Profit" }`. The siblings +are asserted in the same test so a producer that answered `Derived` for *every* +cell could not pass. + +### an_aggregating_formula_cell_is_still_projected_as_an_aggregate + +The negative arm of the same change, and the one a careless fix breaks: gotcha +10 says drilling into a formula cell is supported, so `Aggregate` must still +win where the cell aggregates. Proved discriminating by returning `derived` +before the aggregate branch — the cell then reads `Derived` and becomes +undrillable, with nothing else in the suite noticing. + +### edit_or_drill_refuses_a_formula_owned_cell + +The TUI half. `EditOrDrill` never asked the question either, so the terminal +offered the same editor over the same shadowed cell. Landing only the +projection half would have made the two front ends disagree, which is the +defect design-principles §5 is about. Fault injected by deleting the guard: the +command returns `[SetBuffer{edit}, ChangeMode(Editing)]` again. diff --git a/context/test-evidence/tui.md b/context/test-evidence/tui.md index 374f93ce..dcc5a092 100644 --- a/context/test-evidence/tui.md +++ b/context/test-evidence/tui.md @@ -329,7 +329,7 @@ close. ## `tests/web_demo_model.rs` -### every_visible_cell_in_the_web_demo_is_settable +### every_web_demo_cell_is_settable_except_the_column_a_formula_owns improvise-bck. The demo document the standalone page opens must be one a visitor can *type into*, and the bug is not hypothetical: `examples/demo.improv` leaves @@ -348,6 +348,17 @@ and could agree with itself while disagreeing with the client. It was proved discriminating by pointing it at `examples/demo.improv`, where it names the aggregate at (0,0). +**improvise-ir7l renamed it, because the old name was false.** The demo's +third column is `Profit = Revenue - Cost`, and a formula target is a stored +coordinate, so it projected `Record` and the page offered an editor over it — +the test passed while its name was wrong for a third of the landing-page grid, +which is exactly the shape this repo keeps finding. The assertion is now +two-sided: the two raw columns must be `Record` and the `Profit` column must +be `CellId::Derived`, so it fails if either half moves. Proved discriminating +in both directions by deleting the `derived` branch of `project::cell_id` +(Profit reads back as a `Record`) and, separately, by pointing it at +`examples/demo.improv` again (the aggregate at (0,0) still fails it). + ### the_web_demo_shows_a_formula_beside_its_inputs The payoff a visitor should see without pressing a key: a formula column beside diff --git a/crates/improvise-command/src/command/cmd/mode.rs b/crates/improvise-command/src/command/cmd/mode.rs index bb080243..1640c7f5 100644 --- a/crates/improvise-command/src/command/cmd/mode.rs +++ b/crates/improvise-command/src/command/cmd/mode.rs @@ -188,6 +188,31 @@ mod tests { ); } + /// improvise-ir7l: the cell under the cursor is a formula target, so there is + /// nothing to type into. `context/test-evidence/command.md`. + #[test] + fn edit_or_drill_refuses_a_formula_owned_cell() { + let mut m = two_cat_model(); + m.active_view_mut().set_axis("Type", crate::view::Axis::Row); + m.active_view_mut() + .set_axis("_Measure", crate::view::Axis::Column); + m.model + .add_formula(crate::formula::parse_formula("Profit = 1", "_Measure").expect("parses")); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let dbg = effects_debug( + &EditOrDrill { + edit_mode: AppMode::editing(), + } + .execute(&ctx), + ); + assert!( + dbg.contains("computed by a formula") && !dbg.contains("Editing"), + "a formula cell must refuse rather than open an editor, got: {dbg}" + ); + } + #[test] fn enter_search_mode_sets_flag_and_clears_query() { let m = two_cat_model(); @@ -313,6 +338,18 @@ impl Cmd for EditOrDrill { }; return DrillIntoCell { key }.execute(ctx); } + // The same question `cell_id` asks, off the same predicate: a formula owns + // the cell, so there is nothing to type into (improvise-ir7l). + if let Some(formula) = ctx + .cell_key() + .as_ref() + .and_then(|k| ctx.model.formula_for(k)) + { + return vec![effect::set_status(format!( + "{} is computed by a formula — edit the formula, not the cell", + formula.target + ))]; + } vec![ Box::new(effect::SetBuffer { name: "edit".to_string(), diff --git a/crates/improvise-command/src/ui/project.rs b/crates/improvise-command/src/ui/project.rs index b2f5c85e..a71f6838 100644 --- a/crates/improvise-command/src/ui/project.rs +++ b/crates/improvise-command/src/ui/project.rs @@ -308,11 +308,18 @@ fn cell_id(app: &App, row: usize, col: usize) -> Option { .category(cat) .is_some_and(|category| category.kind.is_regular()) }); + // A derived cell is asked about before a stored one: a formula target is a + // real coordinate, so the record branch would otherwise claim it (ir7l). + let derived = model.formula_for(&key).map(|f| CellId::Derived { + target: f.target.clone(), + }); Some(match aggregated { true => CellId::Aggregate(AggregateHash::of(&key)), - false => CellId::Record(RecordCell { - measure: key.get(MEASURE_CATEGORY).map(String::from), - record: RecordHash::of(&key), + false => derived.unwrap_or_else(|| { + CellId::Record(RecordCell { + measure: key.get(MEASURE_CATEGORY).map(String::from), + record: RecordHash::of(&key), + }) }), }) } @@ -673,7 +680,7 @@ impl ClientSession { .flatten() .filter_map(|cell| match cell.id.as_ref()? { CellId::Record(rc) => Some((rc.record, app.record_version_of(rc.record))), - CellId::Aggregate(_) => None, + CellId::Aggregate(_) | CellId::Derived { .. } => None, }) .collect(); let desired = basis::published(app.document_generation(), app.layout_version(), records); @@ -964,7 +971,7 @@ mod tests { .flatten() .filter_map(|cell| match cell.id.as_ref()? { CellId::Record(rc) => Some((rc.record, app.record_version_of(rc.record))), - CellId::Aggregate(_) => None, + CellId::Aggregate(_) | CellId::Derived { .. } => None, }) .collect(), )); @@ -2201,6 +2208,12 @@ mod cell_identity { /// Region on Row, `_Measure` on Column, nothing regular on None. Three rows and /// two measures, so "the record" is not "the row" or "the cell". fn raw_app() -> App { + let mut app = App::new(raw_workbook(), None); + app.rebuild_layout(); + app + } + + fn raw_workbook() -> Workbook { let mut wb = Workbook::new("Raw"); wb.add_category("Region").unwrap(); for item in ["North", "South", "East"] { @@ -2221,9 +2234,7 @@ mod cell_identity { ); } } - let mut app = App::new(wb, None); - app.rebuild_layout(); - app + wb } /// The same with a second *regular* category left on None, so every visible cell @@ -2259,6 +2270,33 @@ mod cell_identity { app } + /// `raw_app` plus `Profit = Revenue - Cost`, so column 2 is formula-owned and + /// columns 0 and 1 are the raw cells that feed it. + fn formula_app() -> App { + let mut wb = raw_workbook(); + wb.model.add_formula( + improvise_formula::parse_formula("Profit = Revenue - Cost", "_Measure").unwrap(), + ); + let mut app = App::new(wb, None); + app.rebuild_layout(); + app + } + + /// The same document with a second regular category left on None, so the + /// formula column aggregates as well as being derived. + fn aggregating_formula_app() -> App { + let mut wb = raw_workbook(); + wb.add_category("Product").unwrap(); + wb.add_item("Product", "Shirts").unwrap(); + wb.active_view_mut().set_axis("Product", Axis::None); + wb.model.add_formula( + improvise_formula::parse_formula("Profit = Revenue - Cost", "_Measure").unwrap(), + ); + let mut app = App::new(wb, None); + app.rebuild_layout(); + app + } + fn id_at(app: &App, row: usize, col: usize) -> CellId { grid_of(app, WHOLE) .cell(row, col) @@ -2271,7 +2309,7 @@ mod cell_identity { fn record_of_id(id: CellId) -> RecordCell { match id { CellId::Record(cell) => cell, - CellId::Aggregate(hash) => panic!("expected a stored cell, got an aggregate {hash}"), + other => panic!("expected a stored cell, got {other:?}"), } } @@ -2282,6 +2320,31 @@ mod cell_identity { assert!(matches!(id_at(&raw_app(), 0, 0), CellId::Record(_))); } + /// A formula target is a real coordinate, so the record branch claimed it and + /// the browser offered an editor. `context/test-evidence/command.md`. + #[test] + fn a_formula_owned_cell_is_projected_as_derived_and_its_siblings_are_not() { + let app = formula_app(); + assert_eq!( + id_at(&app, 0, 2), + CellId::Derived { + target: "Profit".to_string() + } + ); + assert!(matches!(id_at(&app, 0, 0), CellId::Record(_))); + assert!(matches!(id_at(&app, 0, 1), CellId::Record(_))); + } + + /// Gotcha 10: a formula cell that *aggregates* is still drillable, so the + /// derived answer must not displace the aggregate one. + #[test] + fn an_aggregating_formula_cell_is_still_projected_as_an_aggregate() { + assert!(matches!( + id_at(&aggregating_formula_app(), 0, 2), + CellId::Aggregate(_) + )); + } + #[test] fn a_cell_that_sums_over_a_none_axis_category_is_projected_as_an_aggregate() { assert!(matches!( @@ -2642,7 +2705,7 @@ mod basis_check { .expect("a stored cell is projected with an identity") { CellId::Record(cell) => cell, - CellId::Aggregate(h) => panic!("the fixture must project stored cells, got {h}"), + other => panic!("the fixture must project stored cells, got {other:?}"), } } diff --git a/crates/improvise-core/src/model/types.rs b/crates/improvise-core/src/model/types.rs index 3acb9428..c6f94dab 100644 --- a/crates/improvise-core/src/model/types.rs +++ b/crates/improvise-core/src/model/types.rs @@ -247,6 +247,14 @@ impl Model { &self.formulas } + /// The formula that owns `key`, if one does. **The one home** of "is this + /// cell derived" — every other answer to it is a second derivation. + pub fn formula_for(&self, key: &CellKey) -> Option<&Formula> { + self.formulas + .iter() + .find(|f| key.get(&f.target_category) == Some(f.target.as_str())) + } + /// Names of all categories, including the virtual ones. pub fn category_names(&self) -> Vec<&str> { self.categories.keys().map(|s| s.as_str()).collect() @@ -312,14 +320,10 @@ impl Model { if depth == 0 { return Some(CellValue::Error("circular".into())); } - for formula in &self.formulas { - if let Some(item_val) = key.get(&formula.target_category) - && item_val == formula.target - { - return self.eval_formula_depth(formula, key, depth - 1); - } + match self.formula_for(key) { + Some(formula) => self.eval_formula_depth(formula, key, depth - 1), + None => self.data.get(key).cloned(), } - self.data.get(key).cloned() } /// The aggregation to apply to a cell's raw values, from the measure @@ -426,14 +430,8 @@ impl Model { /// One formula cell for the fixed-point pass: raw aggregation for a /// non-formula ref, the cache for a formula ref. fn evaluate_formula_cell(&self, key: &CellKey, none_cats: &[String]) -> Option { - for formula in &self.formulas { - if let Some(item_val) = key.get(&formula.target_category) - && item_val == formula.target - { - return self.eval_formula_with_cache(formula, key, none_cats); - } - } - None + self.formula_for(key) + .and_then(|formula| self.eval_formula_with_cache(formula, key, none_cats)) } /// The cached evaluator. Its twin is `eval_formula_depth`; a rule stated diff --git a/crates/improvise-protocol/src/render.rs b/crates/improvise-protocol/src/render.rs index 3ea006af..e754c327 100644 --- a/crates/improvise-protocol/src/render.rs +++ b/crates/improvise-protocol/src/render.rs @@ -47,6 +47,9 @@ pub enum CellId { Record(RecordCell), /// An aggregate over stored cells. `Drill` takes this and nothing else. Aggregate(AggregateHash), + /// A cell a formula owns. It carries no surrogate, so a write for it is + /// unconstructible rather than refused — see `context/code/protocol.md` §3. + Derived { target: String }, } /// One cell as the client draws it. Deliberately not a `CellValue` — the diff --git a/crates/improvise-wasm-client/src/keys.rs b/crates/improvise-wasm-client/src/keys.rs index e89c5485..b54f48af 100644 --- a/crates/improvise-wasm-client/src/keys.rs +++ b/crates/improvise-wasm-client/src/keys.rs @@ -462,6 +462,9 @@ fn edit_or_drill(state: &ClientState, mode: AppMode) -> Option> { CellId::Aggregate(_) => Some(vec![Command::SetStatus( "drilling into an aggregate needs the host — not in the browser yet".to_string(), )]), + CellId::Derived { target } => Some(vec![Command::SetStatus(format!( + "{target} is computed by a formula — edit the formula, not the cell" + ))]), } } @@ -526,7 +529,7 @@ fn commit(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option Option { match cell_at_cursor(state)?.id.as_ref()? { CellId::Record(cell) => Some(cell.record), - CellId::Aggregate(_) => None, + CellId::Aggregate(_) | CellId::Derived { .. } => None, } } @@ -1149,6 +1152,23 @@ mod tests { ); } + /// `i` on a formula-owned cell names the formula, so the user learns where + /// the value comes from rather than typing into a shadowed cell (ir7l). + #[test] + fn i_on_a_derived_cell_names_the_formula() { + let client = client_on(CellId::Derived { + target: "Profit".into(), + }); + let KeyOutcome::Local(commands) = resolve(client.state(), ev(KeyCode::Char('i'))) else { + panic!("a derived cell must not be a silent no-op"); + }; + assert!( + matches!(&commands[..], [Command::SetStatus(msg)] if msg.contains("Profit") + && msg.contains("formula")), + "expected a status naming the formula target, got {commands:?}" + ); + } + /// Enter in `Editing` is `[commit-cell-edit editing, clear-buffer edit]` /// and every part shows up here — including the odd-looking trailing clear. #[test] diff --git a/crates/improvise-wasm-client/src/lib.rs b/crates/improvise-wasm-client/src/lib.rs index b6b7e19e..b70f0a30 100644 --- a/crates/improvise-wasm-client/src/lib.rs +++ b/crates/improvise-wasm-client/src/lib.rs @@ -127,11 +127,11 @@ impl Client { } /// The address of the stored cell at a viewport position, or `None` on the - /// three shapes that are not one. The last is not a check (ADR 002). + /// four shapes that are not one. The last is not a check (ADR 002). pub fn record_cell_at(&self, row: usize, col: usize) -> Option { match self.state.grid.cell(row, col)?.id.clone()? { CellId::Record(cell) => Some(cell), - CellId::Aggregate(_) => None, + CellId::Aggregate(_) | CellId::Derived { .. } => None, } } diff --git a/crates/improvise-wasm-client/src/render.rs b/crates/improvise-wasm-client/src/render.rs index e590770a..28fd0cec 100644 --- a/crates/improvise-wasm-client/src/render.rs +++ b/crates/improvise-wasm-client/src/render.rs @@ -305,8 +305,8 @@ mod tests { }) } - /// A 2x2 grid whose four cells cover the three id shapes plus a padded - /// slot, so one fixture answers every editability question. + /// A 2x3 grid whose cells cover the four id shapes plus a padded slot, so + /// one fixture answers every editability question. fn group(cat: &str, name: &str, collapsed: bool) -> FrameEntry { FrameEntry::Group(improvise_protocol::FrameGroup { cat: cat.into(), @@ -351,13 +351,21 @@ mod tests { view, seed: vec![ Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["North", "South"]))), - Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["Revenue", "Total"]))), + Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat([ + "Revenue", "Total", "Profit", + ]))), Projection::Grid(GridUpdate::Cells(vec![ ((0, 0), CellDisplay::number("1200").with_id(record_id())), ( (0, 1), CellDisplay::number("1900").with_id(CellId::Aggregate(aggregate())), ), + ( + (0, 2), + CellDisplay::number("700").with_id(CellId::Derived { + target: "Profit".into(), + }), + ), ((1, 0), CellDisplay::text("pending")), ])), ], @@ -365,7 +373,7 @@ mod tests { client } - /// ADR 002's permission, as the page sees it: three different facts, and + /// ADR 002's permission, as the page sees it: four different facts, and /// only the first is a cell a user may type into. #[test] fn only_a_record_cell_renders_as_editable() { @@ -378,6 +386,10 @@ mod tests { !model.rows[0].cells()[1].editable, "an aggregate is a drill target, and Drill is not on the wire yet" ); + assert!( + !model.rows[0].cells()[2].editable, + "a formula owns the cell, so a write to it would be shadowed on recompute" + ); assert!( !model.rows[1].cells()[0].editable, "id: None is not 'aggregate' — it is 'no projected cell', and not writable either" @@ -396,7 +408,7 @@ mod tests { assert!(model.rows[0].cells()[0].numeric); assert!(!model.rows[1].cells()[0].numeric); assert_eq!(model.rows[0].cells()[0].text, "1200"); - assert_eq!(model.col_levels[0], vec!["Revenue", "Total"]); + assert_eq!(model.col_levels[0], vec!["Revenue", "Total", "Profit"]); assert_eq!(model.rows[1].labels(), ["South"]); } diff --git a/crates/improvise-worker-server/src/worker.rs b/crates/improvise-worker-server/src/worker.rs index a363b6ea..682ba57a 100644 --- a/crates/improvise-worker-server/src/worker.rs +++ b/crates/improvise-worker-server/src/worker.rs @@ -189,7 +189,7 @@ mod tests { fn addressed(grid: &GridCache, row: usize, col: usize) -> RecordCell { match grid.cells[row][col].id.as_ref().expect("a projected cell") { CellId::Record(rc) => rc.clone(), - CellId::Aggregate(_) => panic!("row {row} col {col} is an aggregate, not writable"), + other => panic!("row {row} col {col} is not writable: {other:?}"), } } diff --git a/crates/improvise-worker-server/tests/post_message.rs b/crates/improvise-worker-server/tests/post_message.rs index cd453a1b..8c6357aa 100644 --- a/crates/improvise-worker-server/tests/post_message.rs +++ b/crates/improvise-worker-server/tests/post_message.rs @@ -120,7 +120,7 @@ impl MainThread { fn cell(&self, row: usize, col: usize) -> RecordCell { match self.grid.cells[row][col].id.as_ref().expect("a cell") { CellId::Record(rc) => rc.clone(), - CellId::Aggregate(_) => panic!("({row},{col}) is an aggregate"), + other => panic!("({row},{col}) is not a stored cell: {other:?}"), } } @@ -300,7 +300,7 @@ fn the_vfs_offers_every_document_the_origin_holds() { /// The document the static site actually ships, driven the way the page drives /// it. See `context/test-evidence/clients.md`. #[test] -fn the_shipped_demo_seeds_a_worker_whose_every_cell_is_settable() { +fn the_shipped_demo_seeds_a_worker_whose_raw_cells_are_settable() { let seed = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../web/demo.improv")) .expect("web/demo.improv is where the site build expects it"); @@ -318,13 +318,22 @@ fn the_shipped_demo_seeds_a_worker_whose_every_cell_is_settable() { ); assert_eq!(main.text_at(0, 2), "12,200", "Profit is live on arrival"); - // `cell` panics on an aggregate, which is exactly the failure a visitor - // meets as a cell that does not answer a click (ADR 002). + // `cell` panics on anything but a stored cell, which is exactly the failure a + // visitor meets as a cell that does not answer a click (ADR 002). for row in 0..main.grid.row_frame.len() { - for col in 0..main.grid.col_frame.len() { + for col in 0..2 { main.cell(row, col); } } + // The third column is `Profit`, which a formula owns: the page must not + // offer it, so it carries no surrogate to offer (improvise-ir7l). + for row in 0..main.grid.row_frame.len() { + assert!( + matches!(main.grid.cells[row][2].id, Some(CellId::Derived { .. })), + "row {row}'s Profit cell must be Derived, got {:?}", + main.grid.cells[row][2].id + ); + } // And the payoff the page exists to show: an edit to an input moves the // formula beside it, through the projection stream. diff --git a/tests/web_demo_model.rs b/tests/web_demo_model.rs index 80778831..3a5ffaa7 100644 --- a/tests/web_demo_model.rs +++ b/tests/web_demo_model.rs @@ -11,10 +11,10 @@ fn demo_app() -> App { App::new(workbook, Some(path)) } -/// Every cell the page paints names a stored record, so every cell is one the -/// visitor may click and change. +/// Every cell the page paints is either one the visitor may change or one a +/// formula owns — never an aggregate. `context/test-evidence/tui.md`. #[test] -fn every_visible_cell_in_the_web_demo_is_settable() { +fn every_web_demo_cell_is_settable_except_the_column_a_formula_owns() { let app = demo_app(); let (rows, cols) = (app.layout.row_count(), app.layout.col_count()); assert!(rows > 0 && cols > 0, "the demo grid must not be empty"); @@ -32,15 +32,23 @@ fn every_visible_cell_in_the_web_demo_is_settable() { for r in 0..rows { for c in 0..cols { let cell = grid.cell(r, c).expect("a projected cell at every position"); - match &cell.id { - Some(CellId::Record(_)) => {} - other => panic!( - "cell ({r},{c}) — row {:?}, column {:?} — is {other:?}, not a Record, so the \ - page will show it as un-editable", - grid.row_frame.item(r), - grid.col_frame.item(c), - ), - } + let expected_derived = grid + .col_frame + .item(c) + .is_some_and(|t| t.as_slice() == ["Profit"]); + let ok = match (&cell.id, expected_derived) { + (Some(CellId::Record(_)), false) => true, + (Some(CellId::Derived { target }), true) => target == "Profit", + _ => false, + }; + assert!( + ok, + "cell ({r},{c}) — row {:?}, column {:?} — is {:?}; the two raw columns must be \ + Records the page offers, and Profit must be Derived so it does not", + grid.row_frame.item(r), + grid.col_frame.item(c), + cell.id, + ); } } }