test+docs: derive #304's cap fixtures from MAX_CATEGORIES; record the coupling found (improvise-felo.1)
The duplicate step's cap check already reads the one constant after the merge; its tests filled 12 categories and now fill to the cap. An injected literal-12 check fails keeping_duplicates_separate_is_refused_at_the_category_cap. core.md section 9 gains the inventory of what degrades as categories grow. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
@@ -575,6 +575,66 @@ the outlier, and width is not its cause: it grows 4× when the **records**
|
||||
double, at either width. `RecordRow::group` finds each cell's row with a linear
|
||||
scan (improvise-sk32).
|
||||
|
||||
#### Coupling found
|
||||
|
||||
Raising the constant was used to look for code that assumes few categories.
|
||||
This inventory was taken at felo.1's merge of `gh/main` `4f38a35e`. Line
|
||||
numbers are dated, so re-derive them before citing.
|
||||
|
||||
**Reads the constant (fixed in felo.1).** The wizard's precondition, and the
|
||||
duplicate step's cap check that improvise-cgqt added (`separate_blocked`). A
|
||||
fixture that fills to the cap either derives it or fills until
|
||||
`add_category` refuses.
|
||||
|
||||
**Separate limits, left alone.** None of these is a category count:
|
||||
|
||||
- `MAX_ROW_HEADER_W = 24` (`improvise-protocol/src/render.rs`) is the widest
|
||||
one header level is drawn, in cells. It equals the new cap by coincidence.
|
||||
- `MAX_FEATURES = 64` (`improvise-gp/src/fit.rs`) is the encoded-width budget
|
||||
of one fit.
|
||||
- `CATEGORY_THRESHOLD = 20` (`import/analyzer.rs`) is the distinct-value count
|
||||
at which a text field becomes a `Label`.
|
||||
|
||||
**Degrades as categories grow.** These are not fixed here; each is a place
|
||||
where 24 is worse than 12. Most were already reachable at 12.
|
||||
|
||||
- **The TUI row header is the sum of its levels.** In `src/ui/grid.rs`, around
|
||||
`row_header_width`/`data_area_width` (and
|
||||
`ui::viewport::compute_row_header_width`), each level takes 4 to 24 cells.
|
||||
Categories on Row therefore cost at least 4 cells each, and the data area
|
||||
saturates to zero width with no data drawn.
|
||||
- **Column headers take one line per level, but the keypress path does not
|
||||
count them.** `App`'s `visible_rows` is `term_height − 8`, a fixed allowance
|
||||
that ignores the column levels, the group row and the page bar. So with
|
||||
several categories on Column, the cursor can scroll below the drawn grid.
|
||||
- **The page bar is one unclipped line.** It is `GridWidget::render`'s
|
||||
`page_coords` join. Default seating (`View::on_category_added`) sends every
|
||||
regular category after the second to Page. A 24-category import therefore
|
||||
puts 22 selectors on one line, clips most of them without a mark, and opens
|
||||
on a single slice.
|
||||
- **Side panels do not scroll.** In `src/ui/panel.rs`, an item past
|
||||
`item_height` is not drawn, and the cursor can move onto it unseen. The
|
||||
category panel lists every category plus its expanded items.
|
||||
- **The wizard's field list does not scroll.** Its `ReviewProposals` arm in
|
||||
`src/ui/import_wizard_ui.rs` breaks at the popup's bottom (the popup is at
|
||||
most 30 rows high), unlike the steps that use `scrolling`. About 22 fields
|
||||
fit, so the over-the-cap case cannot show every field it asks the user to
|
||||
fix.
|
||||
- **Loading the store is O(k log k) per cell and O(cells × k) in memory.** It
|
||||
measured 3× from 12 to 24 (the table above).
|
||||
- **A fit declines more columns.** `unaffordable_categories` drops the widest
|
||||
category columns once the encoding passes `MAX_FEATURES`. A wide document
|
||||
is fit on fewer of its categories, and the fit says so.
|
||||
- **`cross_product` is exponential in the categories placed on one axis**, and
|
||||
`prune_empty`'s `has_value` matrix is rows × columns. The cap never bounded
|
||||
either.
|
||||
- **Header repeat suppression costs O(levels) per position and level**
|
||||
(`render.rs::shows`). That is O(rows × levels²) per frame, which is small at
|
||||
24.
|
||||
|
||||
The browser adapts where the TUI does not. `#tiles` and `#categories` scroll,
|
||||
and `web/shell.js` charges each column level to the extent.
|
||||
|
||||
### Seating takes the kind, because a name cannot carry it (improvise-cj4u)
|
||||
|
||||
`View::on_category_added` takes a `CategoryKind` beside the name. It used to
|
||||
|
||||
@@ -3235,7 +3235,7 @@ mod prop_tests {
|
||||
if let Some(c) = m.category_mut("_Measure") { c.add_item("Revenue"); }
|
||||
let key = CellKey::new(vec![("_Measure".into(), "Revenue".into())]);
|
||||
m.set_cell(key.clone(), CellValue::Number(val));
|
||||
let _ = m.add_category(&new_cat); // may hit the 12-cat limit
|
||||
let _ = m.add_category(&new_cat); // may be refused; either way the cell survives
|
||||
prop_assert_eq!(m.evaluate(&key), Some(CellValue::Number(val)));
|
||||
}
|
||||
|
||||
|
||||
@@ -2581,10 +2581,10 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Twelve categories and a collision: the case the user hit. Option (b)
|
||||
/// needs a thirteenth, so it is shown, disabled, with the remedy.
|
||||
/// A full set of categories and a collision: the case the user hit. Option
|
||||
/// (b) needs one more, so it is shown, disabled, with the remedy.
|
||||
fn wizard_at_the_cap() -> ImportWizard {
|
||||
let mut recs = category_records(12);
|
||||
let mut recs = category_records(crate::model::MAX_CATEGORIES);
|
||||
for r in recs.as_array_mut().unwrap() {
|
||||
r["amount"] = json!(1.0);
|
||||
}
|
||||
@@ -2603,7 +2603,8 @@ mod tests {
|
||||
assert_eq!(options.len(), 3);
|
||||
assert_eq!(options[0].disabled, None);
|
||||
let reason = options[1].disabled.clone().expect("separate is disabled");
|
||||
assert!(reason.contains("12"), "{reason}");
|
||||
let cap = crate::model::MAX_CATEGORIES.to_string();
|
||||
assert!(reason.contains(&cap), "names the cap: {reason}");
|
||||
assert!(reason.contains("Skip"), "names the remedy: {reason}");
|
||||
assert_eq!(options[2].disabled, None);
|
||||
|
||||
|
||||
@@ -921,7 +921,7 @@ mod tests {
|
||||
.chain([0])
|
||||
.map(|r| {
|
||||
let mut m = serde_json::Map::new();
|
||||
for i in 0..12 {
|
||||
for i in 0..crate::model::MAX_CATEGORIES {
|
||||
m.insert(format!("c{i:02}"), json!(format!("v{r}")));
|
||||
}
|
||||
m.insert("amount".into(), json!(1.0));
|
||||
@@ -948,13 +948,13 @@ mod tests {
|
||||
buf_text(&buf)
|
||||
}
|
||||
|
||||
/// The reported import: twelve categories and a collision.
|
||||
/// The reported import: a full set of categories and a collision.
|
||||
fn wizard_at_the_cap() -> ImportWizard {
|
||||
let recs: Vec<serde_json::Value> = (0..3)
|
||||
.chain([0])
|
||||
.map(|r| {
|
||||
let mut m = serde_json::Map::new();
|
||||
for i in 0..12 {
|
||||
for i in 0..crate::model::MAX_CATEGORIES {
|
||||
m.insert(format!("c{i:02}"), json!(format!("v{r}")));
|
||||
}
|
||||
m.insert("amount".into(), json!(1.0));
|
||||
|
||||
Reference in New Issue
Block a user