Skip to main content

blockworx_editor/edit/
delete.rs

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