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}