Skip to main content

blockworx/edit/
delete.rs

1//! The delete family (`docs/op-emitter-playbook.md`, 10f): the emitters
2//! that remove what a gesture deleted, and the closure they all walk.
3//!
4//! A delete cascades — a block takes its subtree and everything those
5//! blocks own, a pin takes the wires landing on it, a wire takes its
6//! labels — and the whole closure is emitted as explicit `Crud::Delete`
7//! ops, per the compound-operation rule (`docs/collab-architecture.md`
8//! §4): a gesture lands as the primitive ops it produced. The cascade is
9//! also what keeps the fold's endpoint check unreachable: a pin may not
10//! leave under a wire the same commit does not take with it.
11
12use ahash::{HashSet, HashSetExt};
13use blockworx_doc::{
14    commit::CommitBuilder,
15    document::{Document, IndexedDocument},
16    id::{AreaId, BlockId, ImageId, PinId, RouteId, RouteLabelId, TextId},
17    opcode::{Crud, OpCodes},
18};
19
20use crate::edit::lock::MaterialPin;
21use crate::path::Scope;
22
23/// What a delete gesture names — the doc-crate twin of the legacy
24/// `Deletable` element. No icon variant: an icon is a value on its block,
25/// zeroed by `assets::delete_icon` rather than deleted.
26#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
27pub enum Target {
28    Block(BlockId),
29    Pin(PinId),
30    Text(TextId),
31    Area(AreaId),
32    Image(ImageId),
33    Route(RouteId),
34}
35
36/// Everything a set of roots takes with it: the transitive closure over
37/// containment (a block's subtree and its contents) and wire adjacency (a
38/// pin's routes, a route's labels). Held entities only, each listed once,
39/// blocks ordered ancestors first and every other kind by id — so a
40/// gesture's ops read the same on every run.
41///
42/// Shared with the clipboard (10g): copying a selection walks the same
43/// closure that deleting it does.
44#[derive(Debug)]
45pub(crate) struct Closure {
46    pub(crate) blocks: Vec<BlockId>,
47    pub(crate) pins: Vec<PinId>,
48    pub(crate) routes: Vec<RouteId>,
49    pub(crate) labels: Vec<RouteLabelId>,
50    pub(crate) texts: Vec<TextId>,
51    pub(crate) areas: Vec<AreaId>,
52    pub(crate) images: Vec<ImageId>,
53}
54
55impl Closure {
56    pub(crate) fn of(indexed: &IndexedDocument<'_>, roots: &[Target]) -> Self {
57        let mut blocks = HashSet::new();
58        let mut pins = HashSet::new();
59        let mut routes = HashSet::new();
60        let mut texts = HashSet::new();
61        let mut areas = HashSet::new();
62        let mut images = HashSet::new();
63        let mut pending: Vec<BlockId> = Vec::new();
64
65        for root in roots.iter().copied().filter(|&r| held(indexed, r)) {
66            match root {
67                Target::Block(id) => pending.push(id),
68                Target::Pin(id) => {
69                    pins.insert(id);
70                }
71                Target::Text(id) => {
72                    texts.insert(id);
73                }
74                Target::Area(id) => {
75                    areas.insert(id);
76                }
77                Target::Image(id) => {
78                    images.insert(id);
79                }
80                Target::Route(id) => {
81                    routes.insert(id);
82                }
83            }
84        }
85        while let Some(id) = pending.pop() {
86            let Some(entry) = indexed.index.blocks.get(&id) else {
87                continue;
88            };
89            if !blocks.insert(id) {
90                continue;
91            }
92            pending.extend(&entry.children);
93            pins.extend(&entry.pins);
94            routes.extend(&entry.routes);
95            texts.extend(&entry.texts);
96            areas.extend(&entry.areas);
97            images.extend(&entry.images);
98        }
99        routes.extend(
100            pins.iter()
101                .filter_map(|pin| indexed.index.routes_by_endpoint.get(pin))
102                .flatten(),
103        );
104        let labels = routes
105            .iter()
106            .filter_map(|route| indexed.index.routes.get(route))
107            .flat_map(|entry| &entry.labels)
108            .copied()
109            .collect();
110        Closure {
111            blocks: by_depth(indexed.doc, blocks),
112            pins: sorted(pins),
113            routes: sorted(routes),
114            labels: sorted(labels),
115            texts: sorted(texts),
116            areas: sorted(areas),
117            images: sorted(images),
118        }
119    }
120
121    /// The cascade as ops, each entity dying before whatever owned it:
122    /// labels, wires, pins, annotations, then blocks children first. The
123    /// fold is order-tolerant inside one commit — this order is for
124    /// whoever reads the log.
125    pub(crate) fn push_deletes(&self, builder: &mut CommitBuilder) {
126        builder.extend(
127            self.labels
128                .iter()
129                .map(|&id| OpCodes::RouteLabel(id, Crud::Delete)),
130        );
131        builder.extend(
132            self.routes
133                .iter()
134                .map(|&id| OpCodes::Route(id, Crud::Delete)),
135        );
136        builder.extend(self.pins.iter().map(|&id| OpCodes::Pin(id, Crud::Delete)));
137        builder.extend(self.texts.iter().map(|&id| OpCodes::Text(id, Crud::Delete)));
138        builder.extend(self.areas.iter().map(|&id| OpCodes::Area(id, Crud::Delete)));
139        builder.extend(
140            self.images
141                .iter()
142                .map(|&id| OpCodes::Image(id, Crud::Delete)),
143        );
144        builder.extend(
145            self.blocks
146                .iter()
147                .rev()
148                .map(|&id| OpCodes::Block(id, Crud::Delete)),
149        );
150    }
151}
152
153/// Whether the document still holds the target: an id it never held and
154/// one a previous commit removed are the same absence, and the fold
155/// refuses a delete at either.
156fn held(indexed: &IndexedDocument<'_>, target: Target) -> bool {
157    match target {
158        Target::Block(id) => indexed.index.holds_block(id),
159        Target::Route(id) => indexed.index.routes.contains_key(&id),
160        Target::Pin(id) => indexed.doc.pin(&id).is_some(),
161        Target::Text(id) => indexed.doc.text(&id).is_some(),
162        Target::Area(id) => indexed.doc.area(&id).is_some(),
163        Target::Image(id) => indexed.doc.image(&id).is_some(),
164    }
165}
166
167/// A locked block freezes its pin interface, so a pin named on one
168/// declines by itself and the rest of the gesture proceeds (legacy
169/// `Drawing::delete_pin`, `src/widget/drawing.rs:309`). The block itself is
170/// never frozen against deletion — nor are the pins it takes down with it.
171fn deletable(doc: &Document, target: Target) -> bool {
172    match target {
173        Target::Pin(id) => MaterialPin::of(doc, id).is_some(),
174        Target::Block(_)
175        | Target::Text(_)
176        | Target::Area(_)
177        | Target::Image(_)
178        | Target::Route(_) => true,
179    }
180}
181
182fn sorted<T: Ord>(ids: HashSet<T>) -> Vec<T> {
183    let mut ids: Vec<T> = ids.into_iter().collect();
184    ids.sort_unstable();
185    ids
186}
187
188/// The cascade's blocks, ancestors first — the order a paste needs, and
189/// the reverse of the one a delete emits.
190fn by_depth(doc: &Document, blocks: HashSet<BlockId>) -> Vec<BlockId> {
191    let mut blocks: Vec<BlockId> = blocks.into_iter().collect();
192    blocks.sort_by_key(|&id| (depth(doc, id), id));
193    blocks
194}
195
196/// How many blocks a block sits inside. The fold refuses parent cycles
197/// (`FoldError::BlockCycle`), so walking up always reaches the root.
198fn depth(doc: &Document, block: BlockId) -> usize {
199    let mut depth = 0;
200    let mut current = block;
201    while let Some(parent) = doc
202        .block(&current)
203        .map(|live| live.parent)
204        .filter(|&parent| Scope::from_wire(parent) != Scope::Root)
205    {
206        current = parent;
207        depth += 1;
208    }
209    depth
210}
211
212/// Everything a delete of `targets` would take: the closure over the roots
213/// the document lets go of. Shared with the clipboard (10g) — a cut copies
214/// exactly what it deletes.
215pub(crate) fn closure(indexed: &IndexedDocument<'_>, targets: &[Target]) -> Closure {
216    let roots: Vec<Target> = targets
217        .iter()
218        .copied()
219        .filter(|&target| deletable(indexed.doc, target))
220        .collect();
221    Closure::of(indexed, &roots)
222}
223
224/// Inventory row "Delete Selection": one commit for a whole selection,
225/// dispatching over the rows below. The closure owns the dedupe, so a
226/// selection holding both a block and its own child deletes each thing
227/// once.
228pub fn selection(indexed: &IndexedDocument<'_>, targets: &[Target], builder: &mut CommitBuilder) {
229    closure(indexed, targets).push_deletes(builder);
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use crate::edit::harness::{
236        area_create, block_create, fold, image_create, pin_create, route_create,
237        route_label_create, seals_to_nothing, text_create, wired,
238    };
239    use blockworx_doc::document::DocIndex;
240    use blockworx_doc::{
241        block_model::BlockUpdate,
242        fixtures::{area_id, block_id, image_id, pin_id, route_id, route_label_id, text_id},
243    };
244
245    fn reparent(child: u32, parent: u32) -> OpCodes {
246        OpCodes::Block(
247            block_id(child),
248            Crud::Update(BlockUpdate::Parent(block_id(parent))),
249        )
250    }
251
252    fn lock(id: u32) -> OpCodes {
253        OpCodes::Block(block_id(id), Crud::Update(BlockUpdate::Locked(true)))
254    }
255
256    /// Presence read straight off the document rather than off the index,
257    /// which drops an entity whose owner is gone as well as one that is.
258    fn standing(doc: &Document, target: Target) -> bool {
259        match target {
260            Target::Block(id) => doc.block(&id).is_some(),
261            Target::Pin(id) => doc.pin(&id).is_some(),
262            Target::Text(id) => doc.text(&id).is_some(),
263            Target::Area(id) => doc.area(&id).is_some(),
264            Target::Image(id) => doc.image(&id).is_some(),
265            Target::Route(id) => doc.route(&id).is_some(),
266        }
267    }
268
269    fn label_standing(doc: &Document, label: u32) -> bool {
270        doc.route_label(&route_label_id(label)).is_some()
271    }
272
273    fn assert_standing(doc: &Document, targets: &[Target], labels: &[u32], why: &str) {
274        for &target in targets {
275            assert!(standing(doc, target), "{target:?}: {why}");
276        }
277        for &label in labels {
278            assert!(label_standing(doc, label), "label {label}: {why}");
279        }
280    }
281
282    fn assert_gone(doc: &Document, targets: &[Target], labels: &[u32], why: &str) {
283        for &target in targets {
284            assert!(!standing(doc, target), "{target:?}: {why}");
285        }
286        for &label in labels {
287            assert!(!label_standing(doc, label), "label {label}: {why}");
288        }
289    }
290
291    /// The doomed side of [`scene`]: block 1 and the child block 2 inside
292    /// it, their pins, the wires at both levels, and their annotations.
293    fn subtree() -> Vec<Target> {
294        vec![
295            Target::Block(block_id(1)),
296            Target::Block(block_id(2)),
297            Target::Pin(pin_id(3)),
298            Target::Pin(pin_id(4)),
299            Target::Pin(pin_id(10)),
300            Target::Pin(pin_id(11)),
301            Target::Route(route_id(5)),
302            Target::Route(route_id(12)),
303            Target::Route(route_id(13)),
304            Target::Route(route_id(38)),
305            Target::Text(text_id(7)),
306            Target::Text(text_id(14)),
307            Target::Area(area_id(8)),
308            Target::Area(area_id(15)),
309            Target::Image(image_id(9)),
310        ]
311    }
312
313    /// The surviving side of [`scene`]: the unrelated sibling and
314    /// everything it owns.
315    fn sibling() -> Vec<Target> {
316        vec![
317            Target::Block(block_id(30)),
318            Target::Pin(pin_id(31)),
319            Target::Pin(pin_id(32)),
320            Target::Route(route_id(33)),
321            Target::Text(text_id(35)),
322            Target::Area(area_id(36)),
323            Target::Image(image_id(37)),
324        ]
325    }
326
327    /// Two levels plus a bystander. Block 1 (top) holds pins 3 and 4, wire
328    /// 5 between them, text 7 and area 8; block 2 nests inside it with
329    /// pins 10 and 11, wire 12 between them, text 14, area 15, image 9.
330    /// Wire 13 crosses levels (owned by block 1, landing on the child's pin
331    /// 10). Block 30 is an unrelated top-level sibling with pins 31 and 32,
332    /// wire 33, text 35, area 36, image 37 — and wire 38, which it owns
333    /// but which lands on the doomed pin 10. Every wire carries a label.
334    fn scene() -> Document {
335        let doc = wired();
336        let mut builder = CommitBuilder::new("Furnished two levels and a sibling");
337        builder.extend([
338            block_create(2),
339            reparent(2, 1),
340            pin_create(10, 2),
341            pin_create(11, 2),
342            route_create(12, 2, 10, 11),
343            route_create(13, 1, 3, 10),
344            text_create(14, 2),
345            area_create(15, 2),
346            image_create(9, 2),
347            route_label_create(20, 5),
348            route_label_create(21, 12),
349            route_label_create(22, 13),
350            block_create(30),
351            pin_create(31, 30),
352            pin_create(32, 30),
353            route_create(33, 30, 31, 32),
354            route_label_create(34, 33),
355            text_create(35, 30),
356            area_create(36, 30),
357            image_create(37, 30),
358            route_create(38, 30, 31, 10),
359            route_label_create(39, 38),
360        ]);
361        let doc = fold(builder, &doc);
362
363        let index = DocIndex::of(&doc);
364        assert!(
365            index.blocks[&block_id(1)].children.contains(&block_id(2)),
366            "precondition: block 2 nests inside block 1"
367        );
368        assert!(
369            index.blocks[&Scope::Root.wire_id()]
370                .children
371                .contains(&block_id(30)),
372            "precondition: the sibling is a top-level block of its own"
373        );
374        let crossing = doc.route(&route_id(38)).expect("the crossing wire exists");
375        assert_eq!(
376            (crossing.owner, crossing.to),
377            (block_id(30), pin_id(10)),
378            "precondition: wire 38 is owned outside the subtree and lands inside it"
379        );
380        assert_standing(
381            &doc,
382            &subtree(),
383            &[20, 21, 22, 39],
384            "the scene starts whole",
385        );
386        assert_standing(&doc, &sibling(), &[34], "the scene starts whole");
387        doc
388    }
389
390    #[test]
391    fn deleting_a_block_takes_its_subtree_and_leaves_the_sibling_standing() {
392        let doc = scene();
393        let mut index = DocIndex::default();
394
395        let mut builder = CommitBuilder::new("Deleted a block");
396        selection(
397            &index.view(&doc),
398            &[Target::Block(block_id(1))],
399            &mut builder,
400        );
401        let doc = fold(builder, &doc);
402
403        assert_gone(
404            &doc,
405            &subtree(),
406            &[20, 21, 22, 39],
407            "the cascade takes the whole closure",
408        );
409        assert_standing(
410            &doc,
411            &sibling(),
412            &[34],
413            "an unrelated block keeps everything of its own",
414        );
415    }
416
417    /// The emission order, asserted as the op sequence rather than the end
418    /// state: each entity dies before whatever owned it.
419    #[test]
420    fn a_cascade_emits_each_entity_before_whatever_owned_it() {
421        let doc = scene();
422        let mut index = DocIndex::default();
423
424        let mut builder = CommitBuilder::new("Deleted a block");
425        selection(
426            &index.view(&doc),
427            &[Target::Block(block_id(2))],
428            &mut builder,
429        );
430        let ops = builder.seal().expect("the cascade produced ops");
431
432        assert_eq!(
433            ops.ops(),
434            [
435                OpCodes::RouteLabel(route_label_id(21), Crud::Delete),
436                OpCodes::RouteLabel(route_label_id(22), Crud::Delete),
437                OpCodes::RouteLabel(route_label_id(39), Crud::Delete),
438                OpCodes::Route(route_id(12), Crud::Delete),
439                OpCodes::Route(route_id(13), Crud::Delete),
440                OpCodes::Route(route_id(38), Crud::Delete),
441                OpCodes::Pin(pin_id(10), Crud::Delete),
442                OpCodes::Pin(pin_id(11), Crud::Delete),
443                OpCodes::Text(text_id(14), Crud::Delete),
444                OpCodes::Area(area_id(15), Crud::Delete),
445                OpCodes::Image(image_id(9), Crud::Delete),
446                OpCodes::Block(block_id(2), Crud::Delete),
447            ]
448        );
449    }
450
451    /// The nesting the order test needs: a two-block cascade deletes the
452    /// child before its parent.
453    #[test]
454    fn a_nested_cascade_emits_children_before_parents() {
455        let doc = scene();
456        let mut index = DocIndex::default();
457
458        let mut builder = CommitBuilder::new("Deleted a block");
459        selection(
460            &index.view(&doc),
461            &[Target::Block(block_id(1))],
462            &mut builder,
463        );
464        let ops = builder.seal().expect("the cascade produced ops");
465
466        let blocks: Vec<&OpCodes> = ops
467            .ops()
468            .iter()
469            .filter(|op| matches!(op, OpCodes::Block(..)))
470            .collect();
471        assert_eq!(
472            blocks,
473            [
474                &OpCodes::Block(block_id(2), Crud::Delete),
475                &OpCodes::Block(block_id(1), Crud::Delete),
476            ]
477        );
478    }
479
480    #[test]
481    fn deleting_pins_takes_exactly_the_wires_that_land_on_them() {
482        let doc = scene();
483        let mut index = DocIndex::default();
484        assert_eq!(
485            DocIndex::of(&doc).routes_by_endpoint[&pin_id(10)]
486                .iter()
487                .copied()
488                .collect::<HashSet<RouteId>>(),
489            [route_id(12), route_id(13), route_id(38)]
490                .into_iter()
491                .collect::<HashSet<RouteId>>(),
492            "precondition: three wires at two levels land on the pin"
493        );
494
495        let mut builder = CommitBuilder::new("Deleted a pin");
496        selection(&index.view(&doc), &[Target::Pin(pin_id(10))], &mut builder);
497        let doc = fold(builder, &doc);
498
499        assert_gone(
500            &doc,
501            &[
502                Target::Pin(pin_id(10)),
503                Target::Route(route_id(12)),
504                Target::Route(route_id(13)),
505                Target::Route(route_id(38)),
506            ],
507            &[21, 22, 39],
508            "a pin takes every wire landing on it, and each wire its labels",
509        );
510        assert_standing(
511            &doc,
512            &[
513                Target::Block(block_id(2)),
514                Target::Pin(pin_id(11)),
515                Target::Text(text_id(14)),
516                Target::Image(image_id(9)),
517                Target::Route(route_id(5)),
518            ],
519            &[20],
520            "the pin's owner and its neighbours are untouched",
521        );
522    }
523
524    #[test]
525    fn a_locked_owner_declines_its_own_pins_and_no_others() {
526        let doc = scene();
527        let mut index = DocIndex::default();
528        let mut builder = CommitBuilder::new("Locked a block");
529        builder.push(lock(2));
530        let doc = fold(builder, &doc);
531        assert!(
532            doc.block(&block_id(2))
533                .expect("the locked block exists")
534                .locked,
535            "precondition: the child block's interface is frozen"
536        );
537
538        let mut builder = CommitBuilder::new("Deleted the frozen pins");
539        selection(
540            &index.view(&doc),
541            &[Target::Pin(pin_id(10)), Target::Pin(pin_id(11))],
542            &mut builder,
543        );
544        seals_to_nothing(builder);
545
546        let mut builder = CommitBuilder::new("Deleted a mixed pin selection");
547        selection(
548            &index.view(&doc),
549            &[Target::Pin(pin_id(3)), Target::Pin(pin_id(10))],
550            &mut builder,
551        );
552        let doc = fold(builder, &doc);
553
554        assert_gone(
555            &doc,
556            &[
557                Target::Pin(pin_id(3)),
558                Target::Route(route_id(5)),
559                Target::Route(route_id(13)),
560            ],
561            &[20, 22],
562            "the unfrozen pin goes, wires and labels included",
563        );
564        assert_standing(
565            &doc,
566            &[
567                Target::Pin(pin_id(10)),
568                Target::Route(route_id(12)),
569                Target::Route(route_id(38)),
570            ],
571            &[21, 39],
572            "the frozen pin declines alone — the rest of the gesture still lands",
573        );
574    }
575
576    /// The lock freezes the pin *interface*, not the block: legacy
577    /// `delete_shape` never consulted it, so the block and the pins it
578    /// takes down still go.
579    #[test]
580    fn a_locked_block_still_deletes_with_its_frozen_pins() {
581        let doc = scene();
582        let mut index = DocIndex::default();
583        let mut builder = CommitBuilder::new("Locked a block");
584        builder.push(lock(2));
585        let doc = fold(builder, &doc);
586
587        let mut builder = CommitBuilder::new("Deleted a locked block");
588        selection(
589            &index.view(&doc),
590            &[Target::Block(block_id(2))],
591            &mut builder,
592        );
593        let doc = fold(builder, &doc);
594
595        assert_gone(
596            &doc,
597            &[
598                Target::Block(block_id(2)),
599                Target::Pin(pin_id(10)),
600                Target::Pin(pin_id(11)),
601            ],
602            &[],
603            "a locked block is deletable, interface and all",
604        );
605    }
606
607    #[test]
608    fn deleting_a_route_takes_its_labels_and_leaves_its_endpoints() {
609        let doc = scene();
610        let mut index = DocIndex::default();
611
612        let mut builder = CommitBuilder::new("Deleted a wire");
613        selection(
614            &index.view(&doc),
615            &[Target::Route(route_id(13))],
616            &mut builder,
617        );
618        let doc = fold(builder, &doc);
619
620        assert_gone(&doc, &[Target::Route(route_id(13))], &[22], "the wire dies");
621        assert_standing(
622            &doc,
623            &[
624                Target::Pin(pin_id(3)),
625                Target::Pin(pin_id(10)),
626                Target::Route(route_id(12)),
627            ],
628            &[21],
629            "a wire's endpoints outlive it",
630        );
631    }
632
633    #[test]
634    fn deleting_an_annotation_has_no_fallout() {
635        let doc = scene();
636        let mut index = DocIndex::default();
637
638        let mut builder = CommitBuilder::new("Deleted the annotations");
639        selection(
640            &index.view(&doc),
641            &[Target::Text(text_id(14))],
642            &mut builder,
643        );
644        selection(
645            &index.view(&doc),
646            &[Target::Area(area_id(15))],
647            &mut builder,
648        );
649        selection(
650            &index.view(&doc),
651            &[Target::Image(image_id(9))],
652            &mut builder,
653        );
654        let doc = fold(builder, &doc);
655
656        assert_gone(
657            &doc,
658            &[
659                Target::Text(text_id(14)),
660                Target::Area(area_id(15)),
661                Target::Image(image_id(9)),
662            ],
663            &[],
664            "each annotation dies",
665        );
666        assert_standing(
667            &doc,
668            &[
669                Target::Block(block_id(2)),
670                Target::Pin(pin_id(10)),
671                Target::Route(route_id(12)),
672            ],
673            &[21],
674            "an annotation takes nothing with it",
675        );
676    }
677
678    /// The dedupe: a selection holding both a block and things already
679    /// inside it emits exactly what the block alone would.
680    #[test]
681    fn a_selection_holding_a_block_and_its_own_child_deletes_each_thing_once() {
682        let doc = scene();
683        let mut index = DocIndex::default();
684
685        let mut nested = CommitBuilder::new("Deleted a selection");
686        selection(
687            &index.view(&doc),
688            &[
689                Target::Block(block_id(2)),
690                Target::Block(block_id(1)),
691                Target::Pin(pin_id(10)),
692                Target::Text(text_id(7)),
693                Target::Route(route_id(13)),
694            ],
695            &mut nested,
696        );
697        let mut whole = CommitBuilder::new("Deleted a selection");
698        selection(&index.view(&doc), &[Target::Block(block_id(1))], &mut whole);
699
700        let nested = nested.seal().expect("the selection produced ops");
701        let whole = whole.seal().expect("the cascade produced ops");
702        assert_eq!(
703            nested.ops(),
704            whole.ops(),
705            "roots already inside the closure add nothing to it"
706        );
707    }
708
709    #[test]
710    fn a_selection_deletes_unrelated_targets_together() {
711        let doc = scene();
712        let mut index = DocIndex::default();
713
714        let mut builder = CommitBuilder::new("Deleted a selection");
715        selection(
716            &index.view(&doc),
717            &[Target::Block(block_id(2)), Target::Text(text_id(35))],
718            &mut builder,
719        );
720        let doc = fold(builder, &doc);
721
722        assert_gone(
723            &doc,
724            &[Target::Block(block_id(2)), Target::Text(text_id(35))],
725            &[],
726            "both roots of the selection die in one commit",
727        );
728        assert_standing(
729            &doc,
730            &[Target::Block(block_id(30)), Target::Route(route_id(33))],
731            &[34],
732            "the sibling keeps everything the selection did not name",
733        );
734    }
735
736    /// E3's delete analogue: the fold refuses a delete at an absent
737    /// target, so the emitter filters it out rather than sealing a commit
738    /// the fold will not take.
739    #[test]
740    fn deleting_what_is_already_gone_pushes_nothing() {
741        let doc = scene();
742        let mut index = DocIndex::default();
743        let mut builder = CommitBuilder::new("Deleted a block");
744        selection(
745            &index.view(&doc),
746            &[Target::Block(block_id(2))],
747            &mut builder,
748        );
749        let doc = fold(builder, &doc);
750        assert!(
751            !standing(&doc, Target::Block(block_id(2))),
752            "precondition: the delete removed the block"
753        );
754
755        let redelete = blockworx_doc::fixtures::commit(
756            "Deleted it again by hand",
757            vec![OpCodes::Block(block_id(2), Crud::Delete)],
758        );
759        assert!(
760            doc.try_apply(&redelete).is_err(),
761            "precondition: the fold refuses a delete at an absent target"
762        );
763
764        let mut builder = CommitBuilder::new("Deleted it again");
765        selection(
766            &index.view(&doc),
767            &[
768                Target::Block(block_id(2)),
769                Target::Pin(pin_id(10)),
770                Target::Route(route_id(12)),
771                Target::Text(text_id(14)),
772                Target::Area(area_id(15)),
773                Target::Image(image_id(9)),
774            ],
775            &mut builder,
776        );
777        seals_to_nothing(builder);
778    }
779
780    /// E4: a target the document never held pushes nothing — and must,
781    /// since the fold refuses a commit deleting an id it cannot resolve.
782    #[test]
783    fn absent_targets_push_nothing() {
784        let doc = scene();
785        let mut index = DocIndex::default();
786        assert!(
787            doc.block(&block_id(99)).is_none(),
788            "precondition: the strangers really are absent"
789        );
790
791        let mut builder = CommitBuilder::new("Deleted strangers");
792        selection(
793            &index.view(&doc),
794            &[
795                Target::Block(block_id(99)),
796                Target::Pin(pin_id(98)),
797                Target::Route(route_id(97)),
798                Target::Text(text_id(96)),
799                Target::Area(area_id(95)),
800                Target::Image(image_id(94)),
801            ],
802            &mut builder,
803        );
804        seals_to_nothing(builder);
805    }
806}