docs(context): core.md section 21 and test evidence for displays beside keys, second slice (improvise-cq1q.12)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Claude (adr007-display)
2026-10-08 21:58:46 -07:00
committed by Edward Langley
parent ba053fdaa5
commit 7a7ff81731
10 changed files with 250 additions and 58 deletions
+41 -6
View File
@@ -1758,9 +1758,44 @@ 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.
**What the first slice covers:** the category panel's headers, the tile bar and
the view list, in both front ends.
**What the second slice covers (improvise-cq1q.12):** the grid frame, the page
bar, records-mode headers and coordinate cells, the category panel's item rows,
the view panel's axis line and CSV export. The frame's label tuples are also cell
coordinates (`cell_key`, `innermost_group`, `run_start`), so the display is added
beside the key and never in its place:
- `AxisEntry::DataItem` carries `display` parallel to `tuple`, and `GroupHeader`
a `group_display`. They are built where the layout is, because a folded row's
tuple holds a *group* name where an item name would be and only `expand_category`
knows which. `Group` gained a private `display` for it.
- `AxisFrame` carries `cat_displays`, `FrameEntry::Item` a `display` tuple and
`FrameGroup` a `display`, beside `cats`, `tuple` and `name`. Deserialising
refuses a display list that is not the length of its keys. `header_labels`
decides *whether* a repeat blanks from the keys and draws the display, and
`header_widths` measures what is drawn.
- `PageAxis` carries `cat_display`, `selected_display` and `item_displays`
beside `cat`, `selected` and `items`. A page command is composed from `items`
at an index, so `item_displays` can never be read back as a key.
- `CategoryRow.items` is `Vec<CategoryItem { name, display }>`, and
`ViewSummary.axes` is `Vec<ViewAxis { category, display, axis }>`.
- Records mode: a column header shows the category's or measure's display, and a
coordinate cell's *text* is the item's display (`GridLayout::coordinate_display`).
Its `edit_text` and `value` stay the key, because the editor seeds the rename
from them.
- CSV export writes displays in its corner cell, column headers, row labels and
page note.
- The browser's render model keeps its field names and carries the display in them
(`RenderPageAxis.cat`, `RenderCatRow.label`), so `web/` is unchanged.
Still keys: `GridLayout::row_label`/`col_label` (commands and the status line
match on them), `edit_text`, the active-view marker, and everything status
messages and the view title say (improvise-cq1q.13). A display change alone does
not move `layout_fingerprint`, so it resets no cursor; no production path changes
one yet.
**`.improv` neither writes nor reads a display in this step.** A saved file still
carries keys only, so a display set in memory is lost on save; display persists
only with the new version literal (improvise-cq1q.5).
+91
View File
@@ -2024,3 +2024,94 @@ fails on that rival (no `▶ Overview`), because the active view is a key
The browser half of the same three panels. It also asserts that the view row is
still `active` when found by key.
## Displays in the grid frame, page bar, item rows and CSV (ADR 007 clause 2, improvise-cq1q.12)
The same test-only way to make a display differ from its key (`set_display`,
`set_item_display`, `set_group_display`). Each rival below was injected, the
workspace run, and the named tests failed; the revert was re-run green.
### view::layout::tests::a_layout_shows_displays_and_still_addresses_by_keys
Rivals: an item's display pushed as its key (`display.push(item_name)`); a page
axis's `selected_display` set to `selected`. Both fail on the display asserts,
while `cell_key`, `row_group_for` and `row_run_start` stay on keys.
### view::layout::tests::a_folded_group_is_shown_under_the_groups_display_and_keyed_by_its_name
A folded row's tuple holds the group name where an item name would be, so the
display must come from the group. Not a distinct injection; it covers the arm
that the item test cannot reach.
### view::layout::tests::a_records_grid_shows_displays_in_its_headers_and_coordinates
Rival: `coordinate_display` returns the item key. Fails here and in
`ui::project::tests::a_records_coordinate_shows_its_display_and_edits_its_key`.
### ui::project::tests::the_frame_shows_displays_and_its_key_tuples_still_address_the_cells
The rival the ticket names: `axis_frame` writes `tuple: display.clone()`. The
test rebuilds every cell's `CellKey` from the frame's key tuples, its `cats` and
the page bar, and compares it to `layout.cell_key`, so it fails (and so does the
records test, which looks the Region column up by its key tuple). Also fails when
a group header's display is its key.
### ui::project::tests::each_view_summary_shows_its_own_display_and_its_categories_displays
Rival: every summary takes the ACTIVE view's display. The second view "Plan" has
a display unlike both the active view's ("Overview") and its own key, so the
fault fails here by name.
### ui::project::tests::an_item_row_shows_its_display_and_keeps_its_key
Rival: the item row's display is its key. The unrenamed item "West" must show its
key, and `_Measure`'s "Revenue" shows "Sales".
### ui::project::tests::the_page_bar_carries_displays_beside_the_keys_a_page_command_names
Rival: `selected_display` is the key. Fails here, in the layout test, in the TUI
grid test and in the CSV test.
### render::tests::a_header_draws_displays_and_blanks_a_repeat_by_key
Rival: suppression compares display tuples. West shares East's DISPLAY ("Same")
and must still draw, so comparing displays blanks it.
### render::tests::a_header_is_as_wide_as_what_it_shows
Rival: `header_widths` measures the key tuples. Fails (key "E" against display
"Eastern Seaboard"), as does the TUI grid test.
### render::tests::a_frame_with_ragged_displays_is_refused
Asserts the control parses, then that a `cat_displays` shorter than `cats` and a
display tuple shorter than its key tuple are each refused with their own message.
### resolve::tests::hiding_a_displayed_row_item_names_its_key
Rival: the hide resolver reads the first DISPLAY of the row frame. It composes
`HideItem { item: "Eastern Seaboard" }` and the test fails on `"East"`.
### resolve::tests::picking_a_displayed_page_item_still_names_its_key
Picks index 1 of a page axis whose displays are not its keys and requires the
command to name `Q2`. The index is the only thing that travels from the menu.
### render::tests::the_grid_page_bar_and_item_rows_paint_displays
The browser half. Rival: the cycle's `items` relays the keys, which fails here.
### ui::grid::tests::the_frame_and_page_bar_draw_displays_not_keys
The TUI half. Rival: the group header draws `group.name`; fails on the missing
"▼ Bay". The key assertions use strings that appear only as keys.
### ui::category_panel::tests::an_item_row_draws_its_display_not_its_key
The TUI item row. Not separately injected: its draw path is the one line the
`CatTreeEntry::Item` pattern changed.
### persistence::export::tests::export_csv_writes_displays_in_every_header_and_label
Rival: the column header uses `col_label` (the key). The exact output pins the
corner cell `Kind (Who=Alicia)`, the column header and the row label.
+41 -16
View File
@@ -14,12 +14,12 @@ use improvise_core::workbook::Workbook;
use improvise_io::import::analyzer::{DateComponent, DateShape, FieldKind};
use improvise_io::import::wizard::{DuplicatePolicy, ImportWizard, WizardStep};
use improvise_protocol::{
AxisFrame, Basis, BasisCache, CategoryItem, CategoryRow, CategoryTreeCache, CategoryTreeUpdate, CellDisplay,
CellId, Command, CreateKind, DocumentCache, FormulaCache, FormulaEntry, FrameEntry, FrameGroup,
GridCache, GridExtent, GridUpdate, Projection, RecordCell, Rejection, Snapshot, TileBarCache,
TileEntry, ViewAxis, ViewListCache, ViewState, ViewSummary, WidgetCache, WizardCache, WizardChoice,
WizardCollision, WizardDateField, WizardFieldKind, WizardProposal, WizardStepKind, basis,
reduce_view,
AxisFrame, Basis, BasisCache, CategoryItem, CategoryRow, CategoryTreeCache, CategoryTreeUpdate,
CellDisplay, CellId, Command, CreateKind, DocumentCache, FormulaCache, FormulaEntry,
FrameEntry, FrameGroup, GridCache, GridExtent, GridUpdate, Projection, RecordCell, Rejection,
Snapshot, TileBarCache, TileEntry, ViewAxis, ViewListCache, ViewState, ViewSummary,
WidgetCache, WizardCache, WizardChoice, WizardCollision, WizardDateField, WizardFieldKind,
WizardProposal, WizardStepKind, basis, reduce_view,
};
use crate::ui::app::App;
@@ -350,8 +350,7 @@ fn cell_display(
) -> CellDisplay {
if layout.is_records_mode() {
// The value, not its spelling: this module used to infer numeric-ness
// with `parse::<f64>()`, which called a coordinate `2024` a number. A
// coordinate is drawn under its item's display; the editor keeps the key.
// with `parse::<f64>()`, which called a coordinate `2024` a number.
let text = layout
.coordinate_display(&app.model_state.workbook.model, row, col)
.unwrap_or_else(|| value.as_ref().map(|v| v.to_string()).unwrap_or_default());
@@ -1961,8 +1960,15 @@ mod tests {
let app = app_shown_otherwise();
let grid = grid_of(&app, ALL);
assert_eq!(grid.row_frame.cat_displays, vec!["Sales Area"]);
assert_eq!(grid.row_frame.cats, vec!["Region"], "the key stays beside it");
assert_eq!(grid.col_frame.header_labels(0), vec![vec!["Sales".to_string()]]);
assert_eq!(
grid.row_frame.cats,
vec!["Region"],
"the key stays beside it"
);
assert_eq!(
grid.col_frame.header_labels(0),
vec![vec!["Sales".to_string()]]
);
let header = grid
.row_frame
.entries
@@ -1972,7 +1978,10 @@ mod tests {
_ => None,
})
.expect("East sits under Coastal");
assert_eq!((header.name.as_str(), header.display.as_str()), ("Coastal", "Shoreline"));
assert_eq!(
(header.name.as_str(), header.display.as_str()),
("Coastal", "Shoreline")
);
assert_eq!(
grid.row_frame.header_labels(0)[0],
vec!["Eastern Seaboard".to_string()]
@@ -2008,10 +2017,16 @@ mod tests {
let app = app_shown_otherwise();
let bar = grid_of(&app, ALL).page_coords;
let quarter = &bar[0];
assert_eq!((quarter.cat.as_str(), quarter.selected.as_str()), ("Quarter", "Q1"));
assert_eq!(
(quarter.cat.as_str(), quarter.selected.as_str()),
("Quarter", "Q1")
);
assert_eq!(quarter.cat_display, "Period");
assert_eq!(quarter.selected_display, "First");
assert_eq!(quarter.items.as_deref(), Some(&["Q1".to_string(), "Q2".into()][..]));
assert_eq!(
quarter.items.as_deref(),
Some(&["Q1".to_string(), "Q2".into()][..])
);
assert_eq!(
quarter.item_displays.as_deref(),
Some(&["First".to_string(), "Second".into()][..])
@@ -2046,7 +2061,10 @@ mod tests {
assert_eq!(plan_region.display, "Sales Area");
assert_eq!(plan_region.axis, Axis::Column);
let line = improvise_protocol::view_axis_summary(by_key("Plan"));
assert_eq!(line, "C:Sales Area,Period", "the line draws displays, not keys");
assert_eq!(
line, "C:Sales Area,Period",
"the line draws displays, not keys"
);
}
#[test]
@@ -2063,7 +2081,10 @@ mod tests {
.items()
.position(|t| t == &vec!["Region".to_string()])
.expect("a column per category, found by its key");
assert_eq!(grid.col_frame.header_labels(0)[col], vec!["Sales Area".to_string()]);
assert_eq!(
grid.col_frame.header_labels(0)[col],
vec!["Sales Area".to_string()]
);
let shown: Vec<&str> = grid.cells.iter().map(|r| r[col].text.as_str()).collect();
assert!(shown.contains(&"Eastern Seaboard"), "{shown:?}");
let east = grid
@@ -2072,7 +2093,11 @@ mod tests {
.map(|r| &r[col])
.find(|c| c.text == "Eastern Seaboard")
.unwrap();
assert_eq!(east.edit_text.as_deref(), Some("East"), "the editor seeds the key");
assert_eq!(
east.edit_text.as_deref(),
Some("East"),
"the editor seeds the key"
);
}
/// A pivot-shaped default view plus a records-shaped second one, so a
+3 -3
View File
@@ -73,9 +73,9 @@ pub fn grid_body(
let (key, value) = cell_value(layout, &eval, records, ri, ci);
let fmt = plan.at(key.as_ref());
let text = match records {
true => layout.coordinate_display(model, ri, ci).unwrap_or_else(|| {
value.as_ref().map(|v| v.to_string()).unwrap_or_default()
}),
true => layout
.coordinate_display(model, ri, ci)
.unwrap_or_else(|| value.as_ref().map(|v| v.to_string()).unwrap_or_default()),
false => fmt.value(value.as_ref()),
};
widen(&mut widths, ci, text.width());
+5 -1
View File
@@ -350,7 +350,11 @@ mod tests {
assert_eq!(c.group_display("Coastal"), "Coastal", "no group declared");
c.set_group_display("Coastal", "Shoreline");
assert_eq!(c.group_display("Coastal"), "Shoreline");
assert_eq!(c.group_chain("Coastal"), vec!["Coastal"], "the key is unmoved");
assert_eq!(
c.group_chain("Coastal"),
vec!["Coastal"],
"the key is unmoved"
);
assert_eq!(c.item_display("Nowhere"), "Nowhere", "a formula target");
}
+20 -14
View File
@@ -222,10 +222,9 @@ pub enum AxisEntry {
GroupEnd { level: usize, depth: usize },
/// `collapsed` is not derivable from `tuple`: a group and an item may share a
/// name. Set for a folded group and for everything expanded beneath one.
/// `display` is parallel to `tuple`, one name per level; the tuple is the
/// coordinate and is never replaced by it (core.md §21).
DataItem {
tuple: Vec<String>,
/// Parallel to `tuple`; the tuple is never replaced (core.md §21).
display: Vec<String>,
collapsed: bool,
},
@@ -515,8 +514,6 @@ pub fn page_constraints(axes: &[PageAxis]) -> Vec<(String, String)> {
}
impl PageAxis {
/// The items to cycle and the index `selected` sits at, for the two
/// callers that page. `None` where the axis is not navigable.
/// An axis shown under its own keys, as it is wherever nothing is renamed.
pub fn plain(cat: &str, selected: &str, items: Option<Vec<String>>) -> Self {
Self {
@@ -529,6 +526,8 @@ impl PageAxis {
}
}
/// The items to cycle and the index `selected` sits at, for the two
/// callers that page. `None` where the axis is not navigable.
pub fn cycle(&self) -> Option<(&[String], usize)> {
let items = self.items.as_deref().filter(|i| !i.is_empty())?;
let at = items.iter().position(|i| *i == self.selected).unwrap_or(0);
@@ -624,8 +623,7 @@ impl GridLayout {
cat_display: model.category_display(cat).to_string(),
selected_display: shown(&selected),
selected,
item_displays: navigable
.then(|| items.iter().map(|i| shown(i)).collect()),
item_displays: navigable.then(|| items.iter().map(|i| shown(i)).collect()),
items: navigable.then_some(items),
})
})
@@ -1127,7 +1125,9 @@ impl<'a> CategoryRuns<'a> {
depth,
cat_name: self.cat_name.to_string(),
group_name: group.to_string(),
group_display: category.map_or(*group, |c| c.group_display(group)).to_string(),
group_display: category
.map_or(*group, |c| c.group_display(group))
.to_string(),
collapsed: self
.view
.is_group_collapsed(self.cat_name, &chain[..=depth]),
@@ -1717,16 +1717,19 @@ mod tests {
("Type".into(), "Food".into()),
]))
);
assert_eq!(
layout.row_group_for(0),
Some(("Month".into(), "Q1".into()))
);
assert_eq!(layout.row_group_for(0), Some(("Month".into(), "Q1".into())));
assert_eq!(layout.row_run_start("Month", "Q1", &[]), Some(0));
let page = &layout.page_coords[0];
assert_eq!((page.cat.as_str(), page.selected.as_str()), ("Region", "East"));
assert_eq!(
(page.cat.as_str(), page.selected.as_str()),
("Region", "East")
);
assert_eq!(page.cat_display, "Sales Area");
assert_eq!(page.selected_display, "Eastern Seaboard");
assert_eq!(page.items.as_deref(), Some(&["East".to_string(), "West".into()][..]));
assert_eq!(
page.items.as_deref(),
Some(&["East".to_string(), "West".into()][..])
);
assert_eq!(
page.item_displays.as_deref(),
Some(&["Eastern Seaboard".to_string(), "Pacific".into()][..])
@@ -1772,7 +1775,10 @@ mod tests {
"Sales"
);
assert_eq!(layout.records_display(0, month).as_deref(), Some("Jan"));
assert_eq!(layout.coordinate_display(&wb.model, 0, month).as_deref(), Some("January"));
assert_eq!(
layout.coordinate_display(&wb.model, 0, month).as_deref(),
Some("January")
);
assert_eq!(
layout.coordinate_display(&wb.model, 0, col_of("Revenue").unwrap()),
None,
+6 -6
View File
@@ -34,12 +34,12 @@ pub use reduce::{
records_named, reduce_view,
};
pub use render::{
AxisFrame, CategoryItem, CategoryRow, CategoryTreeCache, CategoryTreeUpdate, CellDisplay, CellId, CreateKind,
DocumentCache, FormulaCache, FormulaEntry, FrameEntry, FrameGroup, GridCache, GridExtent,
GridUpdate, MAX_ROW_HEADER_W, MIN_ROW_HEADER_W, Projection, RecordCell, Snapshot, TileBarCache,
TileEntry, ViewAxis, ViewListCache, ViewSummary, WidgetCache, WizardCache, WizardChoice, WizardCollision,
WizardDateField, WizardFieldKind, WizardProposal, WizardStepKind, header_level_widths,
non_item_refusal, opening_tile,
AxisFrame, CategoryItem, CategoryRow, CategoryTreeCache, CategoryTreeUpdate, CellDisplay,
CellId, CreateKind, DocumentCache, FormulaCache, FormulaEntry, FrameEntry, FrameGroup,
GridCache, GridExtent, GridUpdate, MAX_ROW_HEADER_W, MIN_ROW_HEADER_W, Projection, RecordCell,
Snapshot, TileBarCache, TileEntry, ViewAxis, ViewListCache, ViewSummary, WidgetCache,
WizardCache, WizardChoice, WizardCollision, WizardDateField, WizardFieldKind, WizardProposal,
WizardStepKind, header_level_widths, non_item_refusal, opening_tile,
};
pub use resolve::{
Click, KeyOutcome, OpenedEditor, Request, click, edited, read_buffer, resolve, typed,
+4 -1
View File
@@ -1506,7 +1506,10 @@ mod tests {
collapsed: false,
}],
};
assert_eq!(frame.header_widths(), vec!["Eastern Seaboard".len() as u16 + 1]);
assert_eq!(
frame.header_widths(),
vec!["Eastern Seaboard".len() as u16 + 1]
);
}
#[test]
+35 -10
View File
@@ -2670,7 +2670,11 @@ mod tests {
// produce, so the fixture projects both (improvise-e5pf).
crate::WidgetCache::apply(&mut state.grid, &GridUpdate::Records(mode.is_records()));
state.fold(&Command::ChangeMode(mode));
state.grid.page_coords = vec![crate::PageAxis::plain("Quarter", "Q2", Some(vec!["Q1".into(), "Q2".into(), "Q3".into()]))];
state.grid.page_coords = vec![crate::PageAxis::plain(
"Quarter",
"Q2",
Some(vec!["Q1".into(), "Q2".into(), "Q3".into()]),
)];
// THREE of everything, every panel cursor seated in the MIDDLE: an edge
// makes `move-panel-cursor` answer `Ignored` for the wrong reason.
crate::WidgetCache::apply(
@@ -3991,7 +3995,11 @@ mod tests {
crate::WidgetCache::apply(&mut state.basis, &published());
state.grid.page_coords = vec![
crate::PageAxis::plain("Year", "2024", Some(vec!["2024".into(), "2025".into()])),
crate::PageAxis::plain("Quarter", "Q4", Some(vec!["Q1".into(), "Q2".into(), "Q3".into(), "Q4".into()])),
crate::PageAxis::plain(
"Quarter",
"Q4",
Some(vec!["Q1".into(), "Q2".into(), "Q3".into(), "Q4".into()]),
),
];
state
}
@@ -4040,7 +4048,10 @@ mod tests {
#[test]
fn a_filter_axis_is_projected_but_not_cycled() {
let mut state = paged();
state.grid.page_coords.push(crate::PageAxis::plain("Region", "East", None));
state
.grid
.page_coords
.push(crate::PageAxis::plain("Region", "East", None));
let moved = selections(resolve(&state, ev(KeyCode::Char(']'))));
assert!(
!moved.iter().any(|(cat, _)| cat == "Region"),
@@ -4076,11 +4087,18 @@ mod tests {
fn picking_a_displayed_page_item_still_names_its_key() {
let mut state = paged();
state.grid.page_coords[1].cat_display = "Period".into();
state.grid.page_coords[1].item_displays =
Some(["First", "Second", "Third", "Fourth"].map(String::from).to_vec());
state.grid.page_coords[1].item_displays = Some(
["First", "Second", "Third", "Fourth"]
.map(String::from)
.to_vec(),
);
let commands = click(&state, Click::PageItem { axis: 1, item: 1 });
let [Command::Model { cmd: ModelCommand::SetPageSelection { category, item, .. }, .. }] =
commands.as_slice()
let [
Command::Model {
cmd: ModelCommand::SetPageSelection { category, item, .. },
..
},
] = commands.as_slice()
else {
panic!("expected one page selection, got {commands:?}");
};
@@ -4105,8 +4123,12 @@ mod tests {
let KeyOutcome::Local(commands) = resolve(&state, ev(KeyCode::Char('H'))) else {
panic!("hiding resolves here");
};
let [Command::Model { cmd: ModelCommand::HideItem { category, item, .. }, .. }] =
commands.as_slice()
let [
Command::Model {
cmd: ModelCommand::HideItem { category, item, .. },
..
},
] = commands.as_slice()
else {
panic!("expected one hide, got {commands:?}");
};
@@ -4118,7 +4140,10 @@ mod tests {
#[test]
fn a_filter_axis_offers_no_item_to_pick() {
let mut state = paged();
state.grid.page_coords.push(crate::PageAxis::plain("Region", "East", None));
state
.grid
.page_coords
.push(crate::PageAxis::plain("Region", "East", None));
assert!(
click(&state, Click::PageItem { axis: 2, item: 0 }).is_empty(),
"a Filter axis has no item list, so there is nothing a click could mean"
+4 -1
View File
@@ -951,7 +951,10 @@ mod tests {
));
assert_eq!(model.col_levels, vec![vec!["Sales".to_string()]]);
let bar = &model.page_coords[0];
assert_eq!((bar.cat.as_str(), bar.selected.as_str()), ("Period", "First"));
assert_eq!(
(bar.cat.as_str(), bar.selected.as_str()),
("Period", "First")
);
assert_eq!(bar.cycle.as_ref().unwrap().items, vec!["First", "Second"]);
assert_eq!(model.categories[1].label, "Eastern Seaboard");
}