Skip to main content

brep_render/
history.rs

1//! [`History`] — the engine-owned, editable model recipe: the ordered feature
2//! history plus the rollback index (the feature the model is currently built up
3//! to). This is the SINGLE SOURCE OF TRUTH for the model — the UI keeps NO copy;
4//! it mutates and reads the history only through [`crate::engine_state::EngineState`]
5//! methods. That keeps one engine-owned history (UI-agnostic) and converges with
6//! the in-flight "whole history in Rust" pipeline migration — later this sinks
7//! into `brep-kernel-rs` proper without touching the UI.
8//!
9//! Rolling to a step re-runs `features[0..=rollback]` through the SAME kernel
10//! pipeline: the full feature list stays in the request and `stopAtId` (the
11//! editor's "stop at the expanded feature") halts execution AFTER the rolled-to
12//! feature, so the kernel's incremental history cache is RETAINED across rolls
13//! (no thrash) and the viewport shows the model as of that step.
14
15use serde_json::Value;
16
17/// The cap on the undo (and redo) stack depth — old entries fall off the bottom.
18const MAX_UNDO: usize = 100;
19
20/// A restorable model state: the whole document PLUS the rolled-to step, captured
21/// together so an undo returns both the geometry and the view to the state they
22/// were in right before the mutation.
23#[derive(Debug, Clone)]
24struct Snapshot {
25    request: Value,
26    rollback: usize,
27}
28
29/// The engine-owned mutable history.
30#[derive(Debug, Clone)]
31pub struct History {
32    /// The whole `HistoryRequest` document
33    /// (`{expressions, configurator, features: [...]}`).
34    request: Value,
35    /// Index into `features` the model is rolled to (clamped to the last).
36    rollback: usize,
37    /// Undo/redo over the MODEL document. A snapshot is pushed BEFORE each model
38    /// mutation (edit / add / delete / reorder); roll-to-step is view state and
39    /// is NOT snapshotted. Rapid same-target edits (a slider drag) coalesce into a
40    /// single undo entry via `last_edit_key`. The stacks live here in the engine
41    /// core — the model is engine-owned, so its undo history is too; the UI only
42    /// triggers `undo()` / `redo()`.
43    undo_stack: Vec<Snapshot>,
44    redo_stack: Vec<Snapshot>,
45    /// The coalescing token of the most recently recorded edit (see `checkpoint`).
46    last_edit_key: Option<String>,
47    /// The persistent GLOBAL feature counter: bumped by one on every new-feature
48    /// mint ([`Self::next_feature_id`]), so a new id is `{shortName}{counter}`.
49    /// MONOTONIC and NEVER reused — deleting a feature does not free its number,
50    /// and (deliberately) undo does NOT rewind it, so re-doing an add can't collide
51    /// with a number already handed out. It is kept OFF `self.request` in memory
52    /// (so the undo snapshots that clone `request` never rewind it) and folded into
53    /// the serialized document under `"featureCounter"` so it round-trips save/load
54    /// (see [`Self::request_json`] / [`Self::from_request_json`]).
55    feature_counter: u64,
56}
57
58impl Default for History {
59    fn default() -> Self {
60        Self {
61            request: empty_request(),
62            rollback: 0,
63            undo_stack: Vec::new(),
64            redo_stack: Vec::new(),
65            last_edit_key: None,
66            feature_counter: 0,
67        }
68    }
69}
70
71fn empty_request() -> Value {
72    serde_json::json!({ "expressions": "", "configurator": {}, "features": [] })
73}
74
75/// The trailing run of ASCII digits of `id` read as a number (`"P.CU12"` → 12,
76/// `"Box"` → 0). Trailing digits are ASCII (one byte each), so the slice boundary
77/// is always a valid char boundary; a missing/overflowing run yields 0.
78fn trailing_number(id: &str) -> u64 {
79    let digit_bytes = id
80        .bytes()
81        .rev()
82        .take_while(u8::is_ascii_digit)
83        .count();
84    id[id.len() - digit_bytes..].parse().unwrap_or(0)
85}
86
87impl History {
88    /// Load a whole history document (a saved part file parses as one). Rolls to
89    /// the last feature. Ensures a `features` array exists.
90    pub fn from_request_json(json: &str) -> Result<Self, String> {
91        let mut request: Value =
92            serde_json::from_str(json).map_err(|e| format!("history parse: {e}"))?;
93        if !request.get("features").map(Value::is_array).unwrap_or(false) {
94            if let Some(obj) = request.as_object_mut() {
95                obj.insert("features".into(), Value::Array(Vec::new()));
96            } else {
97                request = empty_request();
98            }
99        }
100        // Lift the persistent feature counter OUT of the document so it lives ONLY
101        // in the struct field: kept off `self.request`, the undo snapshots (which
102        // clone `request`) can never rewind it, and it can't be double-folded on
103        // re-serialize. A document with no stored counter (fresh or saved before
104        // this field existed) safe-inits below.
105        let stored = request
106            .as_object_mut()
107            .and_then(|obj| obj.remove("featureCounter"))
108            .and_then(|value| value.as_u64());
109        let mut history = Self {
110            request,
111            rollback: 0,
112            ..Self::default()
113        };
114        history.rollback = history.len().saturating_sub(1);
115        // Safe init when unstored: start ABOVE the largest numeric suffix already
116        // present among feature ids so the next mint (`{shortName}{counter+1}`)
117        // cannot collide with an existing id. This holds because no shortName ends
118        // in a digit (verified — even `IMPORT3D` ends in `D`), so an id's trailing
119        // digits ARE its numeric suffix and `counter+1` strictly exceeds them all.
120        history.feature_counter = stored.unwrap_or_else(|| history.max_id_suffix());
121        Ok(history)
122    }
123
124    /// The largest trailing-integer suffix among all existing feature ids (0 when
125    /// none carry one) — the floor for a safe counter init on a document with no
126    /// stored `featureCounter` (see [`Self::from_request_json`]).
127    fn max_id_suffix(&self) -> u64 {
128        self.features()
129            .iter()
130            .filter_map(|f| {
131                f.get("inputParams")
132                    .and_then(|p| p.get("id"))
133                    .and_then(Value::as_str)
134            })
135            .map(trailing_number)
136            .max()
137            .unwrap_or(0)
138    }
139
140    /// The features slice (empty if none).
141    pub fn features(&self) -> &[Value] {
142        self.request
143            .get("features")
144            .and_then(Value::as_array)
145            .map(Vec::as_slice)
146            .unwrap_or(&[])
147    }
148
149    fn features_mut(&mut self) -> &mut Vec<Value> {
150        let obj = self
151            .request
152            .as_object_mut()
153            .expect("history request is a JSON object");
154        obj.entry("features")
155            .or_insert_with(|| Value::Array(Vec::new()));
156        obj.get_mut("features")
157            .and_then(Value::as_array_mut)
158            .expect("features is a JSON array")
159    }
160
161    pub fn len(&self) -> usize {
162        self.features().len()
163    }
164
165    pub fn is_empty(&self) -> bool {
166        self.len() == 0
167    }
168
169    /// The rolled-to index, clamped to a valid feature (0 when empty).
170    pub fn rollback(&self) -> usize {
171        self.rollback.min(self.len().saturating_sub(1))
172    }
173
174    pub fn set_rollback(&mut self, index: usize) {
175        self.rollback = if self.is_empty() {
176            0
177        } else {
178            index.min(self.len() - 1)
179        };
180        // Rolling to a step is a view move, not a model edit: it records NO undo
181        // snapshot, but it DOES break the edit-coalescing run so the next edit
182        // starts a fresh undo entry rather than merging with a pre-roll edit.
183        self.last_edit_key = None;
184    }
185
186    pub fn feature_type(&self, index: usize) -> Option<String> {
187        self.features()
188            .get(index)?
189            .get("type")
190            .and_then(Value::as_str)
191            .map(String::from)
192    }
193
194    pub fn feature_id(&self, index: usize) -> Option<String> {
195        self.features()
196            .get(index)?
197            .get("inputParams")
198            .and_then(|p| p.get("id"))
199            .and_then(Value::as_str)
200            .map(String::from)
201    }
202
203    pub fn index_of(&self, id: &str) -> Option<usize> {
204        self.features().iter().position(|f| {
205            f.get("inputParams")
206                .and_then(|p| p.get("id"))
207                .and_then(Value::as_str)
208                == Some(id)
209        })
210    }
211
212    /// The `inputParams` document of the feature at `index` (for the dialog).
213    pub fn feature_params(&self, index: usize) -> Option<Value> {
214        self.features().get(index)?.get("inputParams").cloned()
215    }
216
217    pub fn set_feature_params(&mut self, index: usize, params: Value) {
218        if index >= self.len() {
219            return;
220        }
221        // Coalesce a slider drag (many consecutive edits of the SAME feature) into
222        // one undo entry, keyed by the feature index.
223        self.checkpoint(Some(&format!("param:{index}")));
224        if let Some(feat) = self.features_mut().get_mut(index) {
225            if let Some(obj) = feat.as_object_mut() {
226                obj.insert("inputParams".into(), params);
227            }
228        }
229    }
230
231    pub fn push_feature(&mut self, feature: Value) {
232        self.checkpoint(None);
233        self.features_mut().push(feature);
234    }
235
236    pub fn remove_feature(&mut self, index: usize) {
237        if index >= self.len() {
238            return;
239        }
240        self.checkpoint(None);
241        self.features_mut().remove(index);
242    }
243
244    pub fn swap(&mut self, a: usize, b: usize) {
245        let len = self.len();
246        if a < len && b < len && a != b {
247            self.checkpoint(None);
248            self.features_mut().swap(a, b);
249        }
250    }
251
252    /// Mint the id for a NEW feature: `{base}{N}` where `base` is the feature's
253    /// shortName and `N` is this history's persistent GLOBAL counter, bumped by one
254    /// on every mint (`P.CU` → `P.CU7`, `S` → `S8`). GLOBAL across all feature
255    /// types, MONOTONIC, and NEVER reused — a delete does not free a number and
256    /// undo does not rewind the counter — and it persists across save/load, so two
257    /// features can never receive the same id over the document's whole lifetime.
258    pub fn next_feature_id(&mut self, base: &str) -> String {
259        self.feature_counter += 1;
260        format!("{base}{}", self.feature_counter)
261    }
262
263    /// The `stopAtId`-truncated request that stops AFTER the rolled-to feature —
264    /// the roll-to-step request the pipeline runs. Empty history → empty request.
265    pub fn prefix_request(&self) -> Value {
266        let mut request = self.request.clone();
267        if let Some(id) = self.feature_id(self.rollback()) {
268            if let Some(obj) = request.as_object_mut() {
269                obj.insert("stopAtId".into(), Value::String(id));
270            }
271        }
272        request
273    }
274
275    /// The tree listing for the UI: `{ step, features: [{index, type, id}] }`.
276    pub fn listing_json(&self) -> String {
277        let features: Vec<Value> = self
278            .features()
279            .iter()
280            .enumerate()
281            .map(|(index, _)| {
282                serde_json::json!({
283                    "index": index,
284                    "type": self.feature_type(index).unwrap_or_else(|| "?".into()),
285                    "id": self.feature_id(index).unwrap_or_else(|| "(no id)".into()),
286                })
287            })
288            .collect();
289        serde_json::json!({ "step": self.rollback(), "features": features }).to_string()
290    }
291
292    /// The whole request document (for persistence / debugging), with the
293    /// persistent global feature counter folded back in under `"featureCounter"`
294    /// so it round-trips through save/load (the twin of [`Self::from_request_json`],
295    /// which lifts it back out). Written only when non-zero, so a document that has
296    /// never minted a feature persists byte-for-byte as before (mirrors the
297    /// metadata field's "un-annotated model persists unchanged" convention).
298    pub fn request_json(&self) -> String {
299        if self.feature_counter == 0 {
300            return self.request.to_string();
301        }
302        let mut document = self.request.clone();
303        if let Some(obj) = document.as_object_mut() {
304            obj.insert("featureCounter".into(), Value::from(self.feature_counter));
305        }
306        document.to_string()
307    }
308
309    // --- Undo / redo over the model document ------------------------------
310
311    fn snapshot(&self) -> Snapshot {
312        Snapshot {
313            request: self.request.clone(),
314            rollback: self.rollback,
315        }
316    }
317
318    fn restore(&mut self, snap: Snapshot) {
319        self.request = snap.request;
320        let last = self.len().saturating_sub(1);
321        self.rollback = snap.rollback.min(last);
322    }
323
324    /// Record a pre-mutation snapshot for undo. `coalesce_key` groups a run of
325    /// rapid same-target edits (one slider drag) into a SINGLE undo entry: while
326    /// the same non-empty key repeats, no new snapshot is pushed. A `None` key
327    /// never coalesces, so every structural add/delete/reorder is its own entry.
328    /// Any new snapshot clears the redo stack (a fresh edit forks the timeline)
329    /// and the oldest entry falls off once the stack passes [`MAX_UNDO`].
330    fn checkpoint(&mut self, coalesce_key: Option<&str>) {
331        if coalesce_key.is_some() && coalesce_key == self.last_edit_key.as_deref() {
332            return;
333        }
334        self.undo_stack.push(self.snapshot());
335        if self.undo_stack.len() > MAX_UNDO {
336            self.undo_stack.remove(0);
337        }
338        self.redo_stack.clear();
339        self.last_edit_key = coalesce_key.map(str::to_string);
340    }
341
342    /// Whether an undo step is available (for enabling the toolbar button).
343    pub fn can_undo(&self) -> bool {
344        !self.undo_stack.is_empty()
345    }
346
347    /// Whether a redo step is available.
348    pub fn can_redo(&self) -> bool {
349        !self.redo_stack.is_empty()
350    }
351
352    /// Undo the last model mutation: push the current state onto the redo stack
353    /// and restore the previous document + rolled-to step. Returns whether it
354    /// changed anything (false when the undo stack is empty).
355    pub fn undo(&mut self) -> bool {
356        let Some(prev) = self.undo_stack.pop() else {
357            return false;
358        };
359        self.redo_stack.push(self.snapshot());
360        self.restore(prev);
361        // A distinct undo breaks any coalescing run so the next edit is fresh.
362        self.last_edit_key = None;
363        true
364    }
365
366    /// Redo the last undone model mutation (symmetric with [`Self::undo`]).
367    pub fn redo(&mut self) -> bool {
368        let Some(next) = self.redo_stack.pop() else {
369            return false;
370        };
371        self.undo_stack.push(self.snapshot());
372        self.restore(next);
373        self.last_edit_key = None;
374        true
375    }
376}
377
378// ============================================================================
379// Expressions / configurator accessors (appended — the expressions/parameters
380// panel slice). A SEPARATE `impl` block so concurrent edits to the primary block
381// don't conflict; purely additive over the existing history API.
382//
383// The history document carries an `expressions` source string — the variable
384// sheet feature params evaluate against (a numeric param may be the string
385// `"boxW"`, evaluated by the pipeline's shared expression env). The panel edits
386// this and re-runs; the `configurator` object (typed named inputs) is exposed
387// read-only for display.
388// ============================================================================
389impl History {
390    /// The history document's `expressions` source string (empty when absent or
391    /// stored as `null`). The panel's editor binds to this.
392    pub fn expressions(&self) -> String {
393        self.request
394            .get("expressions")
395            .and_then(Value::as_str)
396            .unwrap_or("")
397            .to_string()
398    }
399
400    /// Replace the `expressions` source string. Snapshotted for undo, coalescing a
401    /// run of keystroke edits into ONE undo entry (like a slider drag) via the
402    /// shared `"expressions"` coalesce key, so a distinct add/edit/roll starts a
403    /// fresh entry. A no-op re-set (same text) still records under the same key.
404    pub fn set_expressions(&mut self, expressions: &str) {
405        self.checkpoint(Some("expressions"));
406        if let Some(obj) = self.request.as_object_mut() {
407            obj.insert(
408                "expressions".into(),
409                Value::String(expressions.to_string()),
410            );
411        }
412    }
413
414    /// The `configurator` object (typed named inputs), or `{}` when absent —
415    /// read-only for the panel's display (deeper configurator editing deferred).
416    pub fn configurator(&self) -> Value {
417        self.request
418            .get("configurator")
419            .cloned()
420            .unwrap_or_else(|| Value::Object(serde_json::Map::new()))
421    }
422}
423
424// ============================================================================
425// Feature `persistentData` accessors (appended — the engine-native sketch mode
426// slice). A SEPARATE `impl` block (like the expressions accessors above) so
427// concurrent edits don't conflict; purely additive over the primary history API.
428//
429// A feature's `persistentData` is the kernel-persisted, non-input state (a
430// SKETCH feature stores its solved `{points, geometries, constraints}` under the
431// `sketch` key and the plane `basis` there). Sketch mode reads that state on
432// enter and writes the edited doc back on commit — mirroring how the ref-select
433// slice reads/writes `inputParams` through `feature_params` / `set_feature_params`.
434// ============================================================================
435impl History {
436    /// The `persistentData` document of the feature at `index` (`None` if the
437    /// feature or the field is absent) — the read twin of [`Self::feature_params`].
438    pub fn feature_persistent_data(&self, index: usize) -> Option<Value> {
439        self.features().get(index)?.get("persistentData").cloned()
440    }
441
442    /// Set a single `key` inside the feature-at-`index`'s `persistentData` object,
443    /// creating (or replacing a non-object) `persistentData` as needed. Snapshotted
444    /// for undo (a structural edit — never coalesced), like an add/delete.
445    pub fn set_feature_persistent_field(&mut self, index: usize, key: &str, value: Value) {
446        if index >= self.len() {
447            return;
448        }
449        self.checkpoint(None);
450        if let Some(feat) = self.features_mut().get_mut(index).and_then(Value::as_object_mut) {
451            let entry = feat
452                .entry("persistentData")
453                .or_insert_with(|| Value::Object(serde_json::Map::new()));
454            if !entry.is_object() {
455                *entry = Value::Object(serde_json::Map::new());
456            }
457            if let Some(obj) = entry.as_object_mut() {
458                obj.insert(key.to_string(), value);
459            }
460        }
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    fn seed() -> History {
469        History::from_request_json(
470            r#"{"features":[
471                {"type":"P.CU","inputParams":{"id":"Box","sizeX":20}},
472                {"type":"P.CY","inputParams":{"id":"Pin","radius":6}},
473                {"type":"B","inputParams":{"id":"Cut","boolean":{"operation":"SUBTRACT","targets":["Pin"]}}}
474            ]}"#,
475        )
476        .unwrap()
477    }
478
479    #[test]
480    fn loads_and_rolls_to_last() {
481        let h = seed();
482        assert_eq!(h.len(), 3);
483        assert_eq!(h.rollback(), 2);
484        assert_eq!(h.feature_type(0).as_deref(), Some("P.CU"));
485        assert_eq!(h.feature_id(2).as_deref(), Some("Cut"));
486    }
487
488    #[test]
489    fn prefix_request_stops_at_rolled_step() {
490        let mut h = seed();
491        h.set_rollback(0);
492        let req = h.prefix_request();
493        assert_eq!(req["stopAtId"].as_str(), Some("Box"));
494        // The full feature list is retained (cache retention), only stopAtId moves.
495        assert_eq!(req["features"].as_array().unwrap().len(), 3);
496    }
497
498    #[test]
499    fn edit_add_delete_reorder() {
500        let mut h = seed();
501        // edit params of "Pin"
502        let idx = h.index_of("Pin").unwrap();
503        h.set_feature_params(idx, serde_json::json!({"id":"Pin","radius":9}));
504        assert_eq!(h.feature_params(idx).unwrap()["radius"], 9);
505        // add
506        h.push_feature(serde_json::json!({"type":"P.CU","inputParams":{"id":"Box"}}));
507        // the persistent counter mints the next id (seed ids carry no numeric
508        // suffix → the counter safe-inits to 0, so the first mint is 1).
509        assert_eq!(h.next_feature_id("Box"), "Box1");
510        // reorder swap 0<->1
511        h.swap(0, 1);
512        assert_eq!(h.feature_id(0).as_deref(), Some("Pin"));
513        // delete "Cut"
514        let cut = h.index_of("Cut").unwrap();
515        h.remove_feature(cut);
516        assert!(h.index_of("Cut").is_none());
517    }
518
519    #[test]
520    fn undo_redo_over_edit_and_add() {
521        let mut h = seed();
522        // A freshly loaded history has nothing to undo/redo.
523        assert!(!h.can_undo());
524        assert!(!h.can_redo());
525        // A roll is view state: it records NO undo entry.
526        h.set_rollback(0);
527        assert!(!h.can_undo());
528
529        // Edit a param → undo reverts it, redo re-applies it.
530        let box_idx = h.index_of("Box").unwrap();
531        assert_eq!(h.feature_params(box_idx).unwrap()["sizeX"], 20);
532        h.set_feature_params(box_idx, serde_json::json!({ "id": "Box", "sizeX": 50 }));
533        assert_eq!(h.feature_params(box_idx).unwrap()["sizeX"], 50);
534        assert!(h.can_undo());
535        assert!(h.undo());
536        assert_eq!(h.feature_params(box_idx).unwrap()["sizeX"], 20);
537        assert!(h.can_redo());
538        assert!(h.redo());
539        assert_eq!(h.feature_params(box_idx).unwrap()["sizeX"], 50);
540
541        // Add a feature → undo removes it, redo brings it back.
542        let n = h.len();
543        h.push_feature(serde_json::json!({ "type": "P.CU", "inputParams": { "id": "Extra" } }));
544        assert_eq!(h.len(), n + 1);
545        assert!(h.undo());
546        assert_eq!(h.len(), n);
547        assert!(h.index_of("Extra").is_none());
548        assert!(h.redo());
549        assert_eq!(h.len(), n + 1);
550        assert!(h.index_of("Extra").is_some());
551
552        // A NEW mutation forks the timeline (clears redo).
553        h.undo();
554        assert!(h.can_redo());
555        h.push_feature(serde_json::json!({ "type": "P.CU", "inputParams": { "id": "Fork" } }));
556        assert!(!h.can_redo());
557    }
558
559    #[test]
560    fn drag_edits_coalesce_into_one_undo() {
561        let mut h = seed();
562        h.set_rollback(0);
563        let box_idx = h.index_of("Box").unwrap();
564        // Simulate a slider DRAG: many consecutive edits of the same feature.
565        for v in [21, 22, 23, 24, 25] {
566            h.set_feature_params(box_idx, serde_json::json!({ "id": "Box", "sizeX": v }));
567        }
568        assert_eq!(h.feature_params(box_idx).unwrap()["sizeX"], 25);
569        // ONE undo reverts the WHOLE drag to the pre-drag value — and that is the
570        // only undo entry the drag produced.
571        assert!(h.undo());
572        assert_eq!(h.feature_params(box_idx).unwrap()["sizeX"], 20);
573        assert!(!h.can_undo());
574
575        // A roll between two edits of the same feature breaks coalescing, so the
576        // two edits become two distinct undo entries.
577        h.set_feature_params(box_idx, serde_json::json!({ "id": "Box", "sizeX": 30 }));
578        h.set_rollback(0);
579        h.set_feature_params(box_idx, serde_json::json!({ "id": "Box", "sizeX": 40 }));
580        assert!(h.undo());
581        assert_eq!(h.feature_params(box_idx).unwrap()["sizeX"], 30);
582        assert!(h.undo());
583        assert_eq!(h.feature_params(box_idx).unwrap()["sizeX"], 20);
584    }
585
586    #[test]
587    fn expressions_get_set_and_undo_coalesces() {
588        let mut h = seed();
589        assert_eq!(h.expressions(), ""); // seed has no expressions field
590        h.set_expressions("boxW = 30;");
591        assert_eq!(h.expressions(), "boxW = 30;");
592        // Consecutive expression edits coalesce into a single undo entry.
593        h.set_expressions("boxW = 40;");
594        h.set_expressions("boxW = 50;");
595        assert_eq!(h.expressions(), "boxW = 50;");
596        assert!(h.undo());
597        // ONE undo reverts the whole coalesced run to the pre-edit (empty) value.
598        assert_eq!(h.expressions(), "");
599        // The configurator accessor returns an object (empty here).
600        assert!(h.configurator().is_object());
601    }
602
603    #[test]
604    fn next_feature_id_is_shortname_plus_global_counter() {
605        let mut h = History::default();
606        // A NEW feature's id is `{shortName}{N}` with N the global counter, bumped
607        // once per mint — the exact ids for a couple of types (incl. dotted ones).
608        assert_eq!(h.next_feature_id("S"), "S1");
609        assert_eq!(h.next_feature_id("P.CU"), "P.CU2");
610        assert_eq!(h.next_feature_id("E"), "E3");
611    }
612
613    #[test]
614    fn counter_is_global_across_types_and_never_reused_on_delete() {
615        let mut h = History::default();
616        // Sketch, then cube, then extrude → S1, P.CU2, E3 (ONE global counter,
617        // not per-type).
618        let s = h.next_feature_id("S");
619        h.push_feature(serde_json::json!({ "type": "S", "inputParams": { "id": s } }));
620        let cu = h.next_feature_id("P.CU");
621        h.push_feature(serde_json::json!({ "type": "P.CU", "inputParams": { "id": cu } }));
622        let e = h.next_feature_id("E");
623        h.push_feature(serde_json::json!({ "type": "E", "inputParams": { "id": e } }));
624        assert_eq!(h.feature_id(0).as_deref(), Some("S1"));
625        assert_eq!(h.feature_id(1).as_deref(), Some("P.CU2"));
626        assert_eq!(h.feature_id(2).as_deref(), Some("E3"));
627
628        // Delete the FIRST feature (S1): its number 1 must NOT be reused — the next
629        // mint is strictly greater than every number handed out so far.
630        let idx = h.index_of("S1").unwrap();
631        h.remove_feature(idx);
632        assert_eq!(h.next_feature_id("P.CY"), "P.CY4");
633    }
634
635    #[test]
636    fn counter_persists_across_serialize_reload() {
637        let mut h = History::default();
638        let first = h.next_feature_id("S"); // S1
639        h.push_feature(serde_json::json!({ "type": "S", "inputParams": { "id": first } }));
640
641        // Serialize → reload through the REAL persistence path (`request_json` →
642        // `from_request_json`), not a hand-built document.
643        let json = h.request_json();
644        assert!(json.contains("\"featureCounter\":1"), "counter is folded in: {json}");
645        let mut reloaded = History::from_request_json(&json).unwrap();
646
647        // The counter CONTINUES from where it left off — no restart, and the new id
648        // cannot collide with the persisted S1.
649        assert_eq!(reloaded.next_feature_id("P.CU"), "P.CU2");
650    }
651
652    #[test]
653    fn unmutated_document_omits_the_counter_key() {
654        // The seed's ids carry no numeric suffix → counter safe-inits to 0 → the
655        // document persists byte-for-byte with NO `featureCounter` key.
656        let h = seed();
657        assert!(!h.request_json().contains("featureCounter"));
658    }
659
660    #[test]
661    fn undo_does_not_rewind_the_counter() {
662        // Snapshots capture only `request` + `rollback`; the counter lives OFF the
663        // request, so undoing an add (a delete) must NOT free the number — otherwise
664        // a redo/new-add could reuse it.
665        let mut h = History::default();
666        let a = h.next_feature_id("P.CU"); // P.CU1
667        h.push_feature(serde_json::json!({ "type": "P.CU", "inputParams": { "id": a } }));
668        assert!(h.undo());
669        assert!(h.index_of("P.CU1").is_none());
670        assert_eq!(h.next_feature_id("P.CU"), "P.CU2");
671    }
672
673    #[test]
674    fn counter_safe_inits_above_existing_id_suffixes() {
675        // A legacy document with NO stored counter: safe-init lifts the counter above
676        // the largest existing numeric suffix so a new id can't collide.
677        let mut h = History::from_request_json(
678            r#"{"features":[
679                {"type":"P.CU","inputParams":{"id":"P.CU5"}},
680                {"type":"S","inputParams":{"id":"S2"}}
681            ]}"#,
682        )
683        .unwrap();
684        // Max suffix is 5 → the next mint is 6.
685        assert_eq!(h.next_feature_id("E"), "E6");
686    }
687
688    #[test]
689    fn trailing_number_reads_the_suffix() {
690        assert_eq!(trailing_number("P.CU12"), 12);
691        assert_eq!(trailing_number("S1"), 1);
692        assert_eq!(trailing_number("Box"), 0);
693        assert_eq!(trailing_number("IMPORT3D"), 0); // ends in a letter
694    }
695}