diff --git a/context/code/core.md b/context/code/core.md index eb5bc2d2..d17f45df 100644 --- a/context/code/core.md +++ b/context/code/core.md @@ -1739,3 +1739,28 @@ must name both lines of a duplicate; `ClaimError` carries only the id, so the loader keeps the line numbers. The virtual views `_records` and `_drill` are not yet in `VIRTUAL_IDS`. improvise-p003 item 1 asks for them, and they arrive with the ADR text fix (improvise-cq1q.1) rather than ahead of it. + +## 21. A display beside the key (ADR 007 clause 2, improvise-cq1q.3) + +`Category`, `Item` and `View` each carry a private `display`. It starts equal to +the key and changes only through `set_display` / `set_item_display`, which move +no key. Until the re-key (improvise-cq1q.4), nothing in production calls those +setters, so `display == name` everywhere and behaviour does not change. The +split exists so that the re-key changes what goes *into* the key, and nothing a +person sees moves with it. + +**The key travels beside the display, never in place of it.** `CategoryRow`, +`TileEntry` and `ViewSummary` each gain a `display` field. The key field +(`name`, `category`) stays exactly what it was, because both front ends compose +commands from it: `resolve.rs` builds `SetAxis`, `RemoveItem` and the rest out of +those strings. Carrying the display *in* the key field would make every +command name a key that does not exist once the two differ. That is the gap +improvise-cq1q.11 names, and cq1q.7 later turns these key fields into ids. The +active-view marker in both view panels still compares keys for the same reason. + +**What this first slice covers:** the category panel's headers, the tile bar and +the view list, in both front ends. Item rows, the grid frame, the page bar, +records-mode labels, CSV export headers, status messages and the view title +still show keys. They follow in later slices of this step, because the grid's +label tuples are also cell coordinates (`cell_key`, `innermost_group`), so their +display has to be added at projection time rather than in place. diff --git a/context/test-evidence/core.md b/context/test-evidence/core.md index 66d8c038..354bceba 100644 --- a/context/test-evidence/core.md +++ b/context/test-evidence/core.md @@ -1998,3 +1998,29 @@ Rival, from the same review: strip the leading `_` before the space step, so that ` Draft` becomes `_draft`. This test fails (`left: "_draft"`), and so does `generated_ids_are_distinct_well_formed_and_never_reserved`. Before this test, the proptest was the only thing that saw the rival. + +## Displays beside keys (ADR 007 clause 2, improvise-cq1q.3, first slice) + +Each renderer test below builds a projection whose display differs from its key. +The test-only way to get one is `set_display`, since no production path makes +them differ yet. Every test failed before the change, with the key drawn where +the display should be: `left: "Region"` against `right: "Sales Area"` in the +projection and wasm tests, and a missing "Sales Area" or "Overview" in the TUI +buffers. + +### ui::project::tests::the_category_panel_shows_a_display_and_keeps_the_key_commands_name + +It finds the row by `name == "Region"` before asserting on the display. A rival +that writes the display into `name` fails the lookup, which covers the half that +keeps commands working. `_Measure` is checked to fall back to its own key. + +### ui::view_panel::tests::a_view_row_draws_its_display_and_marks_the_active_view_by_its_key + +Rival: the active marker compares `summary.display == active_view`. This test +fails on that rival (no `▶ Overview`), because the active view is a key +(`ViewState::active_view`) and its display differs from it. + +### render::tests::the_panels_paint_displays_and_keep_keys_for_what_a_command_names + +The browser half of the same three panels. It also asserts that the view row is +still `active` when found by key. diff --git a/crates/improvise-command/src/ui/cat_tree.rs b/crates/improvise-command/src/ui/cat_tree.rs index fd538c41..b8db1ea7 100644 --- a/crates/improvise-command/src/ui/cat_tree.rs +++ b/crates/improvise-command/src/ui/cat_tree.rs @@ -137,6 +137,7 @@ mod tests { fn cat_name_works_for_both_variants() { let header = CatTreeEntry::Category { name: "Region".into(), + display: "Region".into(), item_count: 2, expanded: false, axis: crate::view::Axis::None, diff --git a/crates/improvise-command/src/ui/project.rs b/crates/improvise-command/src/ui/project.rs index e434bfb5..2204ba03 100644 --- a/crates/improvise-command/src/ui/project.rs +++ b/crates/improvise-command/src/ui/project.rs @@ -426,6 +426,11 @@ fn non_item(category: &str, item: &str) -> CellId { } } +/// What a person sees for a category key; the key itself when no category holds it. +fn category_display<'a>(model: &'a Model, name: &'a str) -> &'a str { + model.category(name).map_or(name, |c| c.display()) +} + /// The categories the category panel draws: **every** category, virtual ones /// included, with their items and their axis (improvise-vjms, gotcha 7). pub fn category_rows(model: &Model, view: &View) -> Vec { @@ -434,6 +439,7 @@ pub fn category_rows(model: &Model, view: &View) -> Vec { .into_iter() .map(|name| CategoryRow { name: name.to_string(), + display: category_display(model, name).to_string(), items: model.effective_item_names(name), // `try_axis_of`, not `axis_of`: a view that has never seen a category // has no entry and `axis_of` panics. Unseen shows as `None`. @@ -463,6 +469,7 @@ pub fn view_summaries(app: &App) -> Vec { .iter() .map(|(name, view)| ViewSummary { name: name.clone(), + display: view.display().to_string(), axes: view .category_axes .iter() @@ -484,6 +491,7 @@ pub fn tiles_of(model: &Model, view: &View) -> Vec { .into_iter() .map(|cat| TileEntry { category: cat.to_string(), + display: category_display(model, cat).to_string(), axis: view.try_axis_of(cat).unwrap_or(Axis::None), kind: model .category(cat) @@ -1793,6 +1801,46 @@ mod tests { app } + /// One category and the active view each shown under a display that differs from their key. + fn app_with_displays_unlike_their_keys() -> App { + let mut app = app_with_data(); + let wb = &mut app.model_state.workbook; + wb.model + .category_mut("Region") + .unwrap() + .set_display("Sales Area"); + wb.active_view_mut().set_display("Overview"); + app + } + + #[test] + fn the_category_panel_shows_a_display_and_keeps_the_key_commands_name() { + let app = app_with_displays_unlike_their_keys(); + let rows = category_rows(&app.model_state.workbook.model, app.view()); + let region = rows.iter().find(|r| r.name == "Region").unwrap(); + assert_eq!(region.display, "Sales Area"); + let measure = rows.iter().find(|r| r.name == "_Measure").unwrap(); + assert_eq!(measure.display, "_Measure"); + } + + #[test] + fn a_tile_shows_a_display_and_keeps_the_key_commands_name() { + let app = app_with_displays_unlike_their_keys(); + let tiles = tiles_of(&app.model_state.workbook.model, app.view()); + let region = tiles.iter().find(|t| t.category == "Region").unwrap(); + assert_eq!(region.display, "Sales Area"); + } + + #[test] + fn the_view_list_shows_a_display_and_keeps_the_key_commands_name() { + let app = app_with_displays_unlike_their_keys(); + let active = app.model_state.workbook.active_view.clone(); + let summaries = view_summaries(&app); + let summary = summaries.iter().find(|s| s.name == active).unwrap(); + assert_eq!(summary.display, "Overview"); + assert_ne!(summary.name, "Overview"); + } + /// A pivot-shaped default view plus a records-shaped second one, so a /// switch between them crosses the boundary `settled_mode` watches. fn app_with_a_records_shaped_second_view() -> App { diff --git a/crates/improvise-core/src/model/category.rs b/crates/improvise-core/src/model/category.rs index f737975f..ac08ae37 100644 --- a/crates/improvise-core/src/model/category.rs +++ b/crates/improvise-core/src/model/category.rs @@ -8,19 +8,27 @@ pub type ItemId = usize; pub struct Item { pub id: ItemId, pub name: String, + /// What a person sees; the key until a rename (ADR 007 clause 2). + display: String, /// Parent group name, if any pub group: Option, } impl Item { pub fn new(id: ItemId, name: impl Into) -> Self { + let name = name.into(); Self { id, - name: name.into(), + display: name.clone(), + name, group: None, } } + pub fn display(&self) -> &str { + &self.display + } + pub fn with_group(mut self, group: impl Into) -> Self { self.group = Some(group.into()); self @@ -129,6 +137,8 @@ impl CategoryKind { pub struct Category { pub id: CategoryId, pub name: String, + /// What a person sees; the key until a rename (ADR 007 clause 2). + display: String, /// Items in insertion order pub items: IndexMap, /// Named groups (hierarchy nodes) @@ -141,9 +151,11 @@ pub struct Category { impl Category { pub fn new(id: CategoryId, name: impl Into) -> Self { + let name = name.into(); Self { id, - name: name.into(), + display: name.clone(), + name, items: IndexMap::new(), groups: Vec::new(), next_item_id: 0, @@ -151,6 +163,23 @@ impl Category { } } + pub fn display(&self) -> &str { + &self.display + } + + /// Changes what a person sees and nothing else: the key does not move. + pub fn set_display(&mut self, display: impl Into) { + self.display = display.into(); + } + + /// Changes an item's display; false when the category has no such item. + pub fn set_item_display(&mut self, item: &str, display: impl Into) -> bool { + self.items + .get_mut(item) + .map(|i| i.display = display.into()) + .is_some() + } + pub fn with_kind(mut self, kind: CategoryKind) -> Self { self.kind = kind; self @@ -264,6 +293,20 @@ mod tests { Category::new(0, "Region") } + #[test] + fn a_display_starts_as_the_key_and_changing_it_moves_no_key() { + let mut c = cat(); + c.add_item("East"); + assert_eq!(c.display(), "Region"); + assert_eq!(c.items["East"].display(), "East"); + c.set_display("Sales Area"); + assert!(c.set_item_display("East", "Eastern")); + assert_eq!((c.name.as_str(), c.display()), ("Region", "Sales Area")); + assert_eq!(c.items["East"].display(), "Eastern"); + assert_eq!(c.items["East"].name, "East"); + assert!(!c.set_item_display("West", "Western"), "no such item"); + } + /// improvise-2lh: call sites ask `of_name` rather than compare literals, /// so both directions of the mapping must agree. #[test] diff --git a/crates/improvise-core/src/view/types.rs b/crates/improvise-core/src/view/types.rs index 448ff94c..72d8205f 100644 --- a/crates/improvise-core/src/view/types.rs +++ b/crates/improvise-core/src/view/types.rs @@ -41,6 +41,8 @@ pub enum GroupFold { #[derive(Clone)] pub struct View { pub name: String, + /// What a person sees; the key until a rename (ADR 007 clause 2). + display: String, /// Axis assignment for each category pub category_axes: IndexMap, /// For page axis: selected item per category @@ -65,6 +67,7 @@ impl std::fmt::Debug for View { use std::collections::{BTreeMap, BTreeSet}; let View { name, + display, category_axes, page_selections, hidden_items, @@ -86,6 +89,7 @@ impl std::fmt::Debug for View { .collect(); f.debug_struct("View") .field("name", name) + .field("display", display) .field("category_axes", category_axes) .field("page_selections", &pages) .field("hidden_items", &sorted(hidden_items)) @@ -103,9 +107,20 @@ impl View { VIRTUAL_VIEW_NAMES.contains(&self.name.as_str()) } + pub fn display(&self) -> &str { + &self.display + } + + /// Changes what a person sees and nothing else: the key does not move. + pub fn set_display(&mut self, display: impl Into) { + self.display = display.into(); + } + pub fn new(name: impl Into) -> Self { + let name = name.into(); Self { - name: name.into(), + display: name.clone(), + name, category_axes: IndexMap::new(), page_selections: HashMap::new(), hidden_items: HashMap::new(), @@ -375,6 +390,14 @@ mod tests { use crate::view::Axis; use std::num::NonZeroUsize; + #[test] + fn a_view_display_starts_as_the_key_and_changing_it_moves_no_key() { + let mut v = View::new("Default"); + assert_eq!(v.display(), "Default"); + v.set_display("Overview"); + assert_eq!((v.name.as_str(), v.display()), ("Default", "Overview")); + } + /// improvise-vw47: the count is the starting guess, never the answer. /// What a taken name costs: `context/test-evidence/core.md`. #[test] diff --git a/crates/improvise-protocol/src/client.rs b/crates/improvise-protocol/src/client.rs index d443368e..6b8cefd2 100644 --- a/crates/improvise-protocol/src/client.rs +++ b/crates/improvise-protocol/src/client.rs @@ -514,6 +514,7 @@ mod tests { live.fold(&Command::Project(Projection::CategoryTree( CategoryTreeUpdate::Categories(vec![CategoryRow { name: "Region".into(), + display: "Region".into(), items: vec!["North".into(), "South".into()], axis: Axis::Row, }]), @@ -523,11 +524,13 @@ mod tests { ]))); live.fold(&Command::Project(Projection::TileBar(vec![TileEntry { category: "Region".into(), + display: "Region".into(), kind: crate::CategoryKind::Regular, axis: Axis::Row, }]))); live.fold(&Command::Project(Projection::ViewList(vec![ViewSummary { name: "Default".into(), + display: "Default".into(), axes: vec![("Region".into(), Axis::Row)], }]))); live.fold(&Command::SetSelected { row: 1, col: 1 }); diff --git a/crates/improvise-protocol/src/reduce.rs b/crates/improvise-protocol/src/reduce.rs index e4c0e053..051a738d 100644 --- a/crates/improvise-protocol/src/reduce.rs +++ b/crates/improvise-protocol/src/reduce.rs @@ -556,10 +556,12 @@ mod tests { Command::Project(Projection::Formulas(vec!["Profit = Revenue - Cost".into()])), Command::Project(Projection::ViewList(vec![ViewSummary { name: "Default".into(), + display: "Default".into(), axes: vec![("Region".into(), Axis::Row)], }])), Command::Project(Projection::TileBar(vec![TileEntry { category: "Region".into(), + display: "Region".into(), kind: crate::CategoryKind::Regular, axis: Axis::Page, }])), diff --git a/crates/improvise-protocol/src/render.rs b/crates/improvise-protocol/src/render.rs index 265d4b96..4727eade 100644 --- a/crates/improvise-protocol/src/render.rs +++ b/crates/improvise-protocol/src/render.rs @@ -759,6 +759,8 @@ impl WidgetCache for GridCache { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CategoryRow { pub name: String, + /// What the panel shows; `name` stays what a command names (ADR 007). + pub display: String, pub items: Vec, pub axis: Axis, } @@ -857,6 +859,8 @@ impl WidgetCache for FormulaCache { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ViewSummary { pub name: String, + /// What the view panel shows; `name` stays what a command names. + pub display: String, /// `(category, axis)` for the categories this view displays. pub axes: Vec<(String, Axis)>, } @@ -881,6 +885,8 @@ impl WidgetCache for ViewListCache { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TileEntry { pub category: String, + /// What the tile shows; `category` stays what a command names. + pub display: String, pub axis: Axis, pub kind: CategoryKind, } @@ -1072,6 +1078,7 @@ mod tests { fn tile(category: &str, axis: Axis, kind: CategoryKind) -> TileEntry { TileEntry { category: category.to_string(), + display: category.to_string(), axis, kind, } @@ -1156,6 +1163,7 @@ mod tests { fn cat(name: &str, items: &[&str], axis: Axis) -> CategoryRow { CategoryRow { name: name.into(), + display: name.into(), items: items.iter().map(|s| (*s).to_string()).collect(), axis, } @@ -1801,12 +1809,14 @@ mod tests { let views = ViewListCache::folded(&[vec![ViewSummary { name: "Default".into(), + display: "Default".into(), axes: vec![("Region".into(), Axis::Row)], }]]); assert_eq!(views.views[0].name, "Default"); let tiles = TileBarCache::folded(&[vec![TileEntry { category: "Region".into(), + display: "Region".into(), axis: Axis::Page, kind: crate::CategoryKind::Regular, }]]); @@ -2201,10 +2211,12 @@ mod tests { Projection::Formulas(vec!["Profit = Revenue - Cost".into()]), Projection::ViewList(vec![ViewSummary { name: "Default".into(), + display: "Default".into(), axes: vec![("Region".into(), Axis::None)], }]), Projection::TileBar(vec![TileEntry { category: "Region".into(), + display: "Region".into(), axis: Axis::Page, kind: crate::CategoryKind::Regular, }]), @@ -2242,6 +2254,7 @@ mod tests { Projection::Formulas(vec!["Profit = Revenue - Cost".into()]), Projection::TileBar(vec![TileEntry { category: "Region".into(), + display: "Region".into(), axis: Axis::Page, kind: crate::CategoryKind::Regular, }]), diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 1f9113d4..78cba1da 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -1892,11 +1892,13 @@ mod tests { crate::CategoryTreeUpdate::Categories(vec![ crate::CategoryRow { name: "Region".into(), + display: "Region".into(), items: vec!["North".into(), "South".into()], axis: Axis::Row, }, crate::CategoryRow { name: "Product".into(), + display: "Product".into(), items: vec!["Shirts".into()], axis: Axis::Column, }, @@ -1975,11 +1977,13 @@ mod tests { state.tiles.tiles = vec![ crate::TileEntry { category: "Region".into(), + display: "Region".into(), axis: Axis::Row, kind: crate::CategoryKind::Regular, }, crate::TileEntry { category: "Product".into(), + display: "Product".into(), axis: Axis::Column, kind: crate::CategoryKind::Regular, }, @@ -2334,10 +2338,12 @@ mod tests { state.fold(&Command::Project(Projection::ViewList(vec![ crate::ViewSummary { name: "Default".into(), + display: "Default".into(), axes: vec![], }, crate::ViewSummary { name: "Plan".into(), + display: "Plan".into(), axes: vec![], }, ]))); @@ -2617,6 +2623,7 @@ mod tests { .into_iter() .map(|name| crate::CategoryRow { name: name.into(), + display: name.into(), items: vec!["North".into(), "South".into()], axis: Axis::Row, }) @@ -2635,6 +2642,7 @@ mod tests { .into_iter() .map(|name| crate::ViewSummary { name: name.into(), + display: name.into(), axes: Vec::new(), }) .collect(); @@ -2642,6 +2650,7 @@ mod tests { .into_iter() .map(|category| crate::TileEntry { category: category.into(), + display: category.into(), axis: Axis::Row, kind: crate::CategoryKind::Regular, }) @@ -3747,6 +3756,7 @@ mod tests { let mut tiles = in_mode(AppMode::TileSelect); tiles.tiles.tiles = vec![crate::TileEntry { category: "Region".into(), + display: "Region".into(), axis: improvise_core::view::Axis::Row, kind: crate::CategoryKind::Regular, }]; diff --git a/crates/improvise-protocol/src/viewmodel.rs b/crates/improvise-protocol/src/viewmodel.rs index d216a444..a145f645 100644 --- a/crates/improvise-protocol/src/viewmodel.rs +++ b/crates/improvise-protocol/src/viewmodel.rs @@ -11,9 +11,10 @@ use crate::render::{CategoryTreeCache, ViewSummary}; /// so the entry list *is* what the panel draws. #[derive(Debug, Clone, PartialEq, Eq)] pub enum CatTreeEntry { - /// Category header row: name, item count, expanded? + /// Category header row: name (the key), what it shows, item count, expanded? Category { name: String, + display: String, item_count: usize, expanded: bool, axis: Axis, @@ -49,6 +50,7 @@ pub fn flatten_cat_tree( let is_expanded = expanded.contains(&row.name); entries.push(CatTreeEntry::Category { name: row.name.clone(), + display: row.display.clone(), item_count: row.items.len(), expanded: is_expanded, axis: row.axis, @@ -94,6 +96,7 @@ mod tests { .iter() .map(|(name, items)| CategoryRow { name: (*name).to_string(), + display: (*name).to_string(), items: items.iter().map(|i| i.to_string()).collect(), axis: Axis::None, }) @@ -112,6 +115,7 @@ mod tests { tree, vec![CatTreeEntry::Category { name: "Region".into(), + display: "Region".into(), item_count: 2, expanded: false, axis: Axis::None, diff --git a/crates/improvise-wasm-client/src/render.rs b/crates/improvise-wasm-client/src/render.rs index 48d89256..e99c3c64 100644 --- a/crates/improvise-wasm-client/src/render.rs +++ b/crates/improvise-wasm-client/src/render.rs @@ -361,7 +361,7 @@ fn view_rows(state: &ClientState) -> Option> { .iter() .enumerate() .map(|(i, summary)| RenderViewRow { - name: summary.name.clone(), + name: summary.display.clone(), axes: improvise_protocol::viewmodel::view_axis_summary(summary), active: summary.name == state.view.active_view, selected: i == state.view.view_panel_cursor, @@ -396,7 +396,7 @@ fn tile_row(state: &ClientState) -> Vec { .iter() .enumerate() .map(|(i, tile)| RenderTile { - category: tile.category.clone(), + category: tile.display.clone(), axis: tile.axis.plain_label(), selected: selecting && i == state.view.tile_cat_idx, }) @@ -468,12 +468,13 @@ impl RenderCatRow { fn of(entry: &CatTreeEntry, selected: bool) -> Self { match entry { CatTreeEntry::Category { - name, + display, item_count, expanded, axis, + .. } => Self { - label: name.clone(), + label: display.clone(), is_category: true, axis: axis.initial().to_string(), item_count: Some(*item_count), @@ -806,11 +807,13 @@ mod tests { CategoryTreeUpdate::Categories(vec![ CategoryRow { name: "Region".into(), + display: "Region".into(), items: vec!["North".into(), "South".into()], axis: Axis::Row, }, CategoryRow { name: "Measure".into(), + display: "Measure".into(), items: vec!["Revenue".into()], axis: Axis::Column, }, @@ -843,6 +846,39 @@ mod tests { ); } + #[test] + fn the_panels_paint_displays_and_keep_keys_for_what_a_command_names() { + let mut client = init(); + let state = client.state_mut(); + state.fold(&Command::Project(Projection::CategoryTree( + CategoryTreeUpdate::Categories(vec![CategoryRow { + name: "Region".into(), + display: "Sales Area".into(), + items: vec!["North".into()], + axis: Axis::Row, + }]), + ))); + state.fold(&Command::Project(Projection::ViewList(vec![ViewSummary { + name: "Default".into(), + display: "Overview".into(), + axes: vec![], + }]))); + state.fold(&Command::Project(Projection::TileBar(vec![TileEntry { + category: "Region".into(), + display: "Sales Area".into(), + axis: Axis::Row, + kind: improvise_protocol::CategoryKind::Regular, + }]))); + state.view.view_panel_open = true; + state.view.active_view = "Default".into(); + let model = client.model(); + assert_eq!(model.categories[0].label, "Sales Area"); + assert_eq!(model.tiles[0].category, "Sales Area"); + let views = model.views.expect("the view panel is open"); + assert_eq!(views[0].name, "Overview"); + assert!(views[0].active, "the active view is still found by its key"); + } + fn client_with_panels() -> Client { let mut client = init(); client @@ -850,10 +886,12 @@ mod tests { .fold(&Command::Project(Projection::ViewList(vec![ ViewSummary { name: "Default".into(), + display: "Default".into(), axes: vec![("Region".into(), Axis::Row), ("_Dim".into(), Axis::Column)], }, ViewSummary { name: "Plan".into(), + display: "Plan".into(), axes: vec![], }, ]))); @@ -867,11 +905,13 @@ mod tests { .fold(&Command::Project(Projection::TileBar(vec![ TileEntry { category: "Region".into(), + display: "Region".into(), axis: Axis::Row, kind: improvise_protocol::CategoryKind::Regular, }, TileEntry { category: "Product".into(), + display: "Product".into(), axis: Axis::None, kind: improvise_protocol::CategoryKind::Regular, }, @@ -1192,11 +1232,13 @@ mod tests { .fold(&Command::Project(Projection::TileBar(vec![ TileEntry { category: "Quarter".into(), + display: "Quarter".into(), axis: Axis::Page, kind: improvise_protocol::CategoryKind::Regular, }, TileEntry { category: "Region".into(), + display: "Region".into(), axis: Axis::Column, kind: improvise_protocol::CategoryKind::Regular, }, diff --git a/src/ui/category_panel.rs b/src/ui/category_panel.rs index 7bfb865e..70530163 100644 --- a/src/ui/category_panel.rs +++ b/src/ui/category_panel.rs @@ -73,14 +73,15 @@ impl PanelContent for CategoryContent { match &self.tree[index] { CatTreeEntry::Category { - name, + display, item_count, expanded, axis, + .. } => { let indicator = if *expanded { "▼" } else { "▶" }; let (axis_str, axis_style) = axis_display(*axis); - let name_part = format!("{indicator} {name} ({item_count})"); + let name_part = format!("{indicator} {display} ({item_count})"); let axis_part = format!(" [{axis_str}]"); buf.set_string(inner.x, y, &name_part, base_style); @@ -114,6 +115,7 @@ mod tests { let cache = CategoryTreeCache { categories: vec![CategoryRow { name: name.to_string(), + display: name.to_string(), axis: Axis::Row, items: vec!["x".to_string()], }], @@ -129,6 +131,29 @@ mod tests { buf.content()[31..59].iter().map(|c| c.symbol()).collect() } + #[test] + fn a_category_header_draws_its_display_not_its_key() { + let cache = CategoryTreeCache { + categories: vec![CategoryRow { + name: "Region".to_string(), + display: "Sales Area".to_string(), + axis: Axis::Row, + items: vec!["x".to_string()], + }], + }; + let area = Rect::new(0, 0, 30, 4); + let mut buf = Buffer::empty(area); + Panel::new( + CategoryContent::new(&cache, &Default::default()), + &AppMode::Normal, + 0, + ) + .render(area, &mut buf); + let row: String = buf.content()[31..59].iter().map(|c| c.symbol()).collect(); + assert!(row.contains("Sales Area"), "{row}"); + assert!(!row.contains("Region"), "{row}"); + } + /// improvise-8l0c — `context/test-evidence/tui.md`. #[test] fn the_axis_tag_sits_beside_a_multibyte_category_name() { diff --git a/src/ui/tile_bar.rs b/src/ui/tile_bar.rs index 75dc0d27..65d74e12 100644 --- a/src/ui/tile_bar.rs +++ b/src/ui/tile_bar.rs @@ -79,7 +79,7 @@ impl<'a> Widget for TileBar<'a> { .iter() .map(|tile| { let (axis_symbol, _) = TileBar::axis_display(tile.axis); - format!(" [{} {axis_symbol}] ", tile.category) + format!(" [{} {axis_symbol}] ", tile.display) }) .collect(); let widths: Vec = labels.iter().map(|l| l.width() as u16).collect(); @@ -190,6 +190,23 @@ mod tests { assert_eq!(was, now); } + #[test] + fn a_tile_draws_its_display_not_its_key() { + let mut app = app_with_two_categories(); + let wb = &mut app.model_state.workbook; + wb.model + .category_mut("Region") + .unwrap() + .set_display("Sales Area"); + let cache = project::tile_bar_of(&app); + let area = Rect::new(0, 0, 80, 1); + let mut buf = Buffer::empty(area); + TileBar::new(&cache, &AppMode::Normal, 0).render(area, &mut buf); + let text = buf_text(&buf); + assert!(text.contains("Sales Area"), "{text}"); + assert!(!text.contains("Region"), "{text}"); + } + /// Pins the rendered line. The equivalence test above says the *inputs* /// are unchanged by the migration; this says the drawing of them is. #[test] diff --git a/src/ui/view_panel.rs b/src/ui/view_panel.rs index 7083cf3d..c2081c49 100644 --- a/src/ui/view_panel.rs +++ b/src/ui/view_panel.rs @@ -62,7 +62,7 @@ impl PanelContent for ViewContent<'_> { }; let prefix = if is_active_view { "▶ " } else { " " }; - let name_text = format!("{prefix}{}", summary.name); + let name_text = format!("{prefix}{}", summary.display); let y = inner.y + index as u16; buf.set_string(inner.x, y, &name_text, style); @@ -98,6 +98,7 @@ mod tests { let cache = ViewListCache { views: vec![ViewSummary { name: view_name.to_string(), + display: view_name.to_string(), axes: vec![("Region".to_string(), Axis::Row)], }], }; @@ -119,6 +120,25 @@ mod tests { ); } + #[test] + fn a_view_row_draws_its_display_and_marks_the_active_view_by_its_key() { + use crate::protocol::ViewSummary; + use crate::ui::panel::Panel; + use ratatui::{buffer::Buffer, widgets::Widget}; + let cache = ViewListCache { + views: vec![ViewSummary { + name: "Default".to_string(), + display: "Overview".to_string(), + axes: vec![], + }], + }; + let area = Rect::new(0, 0, 30, 4); + let mut buf = Buffer::empty(area); + Panel::new(ViewContent::new(&cache, "Default"), &AppMode::Normal, 0).render(area, &mut buf); + let row: String = buf.content()[31..59].iter().map(|c| c.symbol()).collect(); + assert!(row.contains("▶ Overview"), "{row}"); + } + fn app_with_two_views() -> App { let mut wb = Workbook::new("Test"); wb.add_category("Region").unwrap();