1use ahash::HashMap;
21use base64::Engine as _;
22use blockworx_doc::{
23 block_model::{Asset, Block, Icon, Label, Pin},
24 commit::Commit,
25 document::Document,
26 geometry::{GridPoint, GridRect},
27 id::{BlockId, Id, IdKind, PinId, RouteId},
28 opcode::{Crud, OpCodes},
29 repo::Repo,
30 values::{LabelSide, PinSide},
31};
32use uuid::Uuid;
33
34use crate::edit::lower::{
35 accent_from_role, artwork_rect, schema_label_side, schema_pin_dir, schema_pin_side,
36};
37use crate::path::Scope;
38use crate::schema::loc::format_loc;
39use crate::schema::model as schema;
40
41struct Names {
46 blocks: HashMap<BlockId, String>,
47 pins: HashMap<PinId, (BlockId, String)>,
48 created: CreationOrder,
49}
50
51impl Names {
52 fn rank<K: IdKind>(&self, id: &Id<K>) -> Rank {
53 self.created.rank(id)
54 }
55}
56
57#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
62struct Rank(usize);
63
64impl Rank {
65 const UNCREATED: Rank = Rank(usize::MAX);
66}
67
68struct CreationOrder(HashMap<Uuid, Rank>);
74
75impl CreationOrder {
76 fn of(log: &[Commit]) -> Self {
77 let mut ranks: HashMap<Uuid, Rank> = HashMap::default();
78 for id in log.iter().flat_map(Commit::ops).filter_map(minted) {
79 let next = Rank(ranks.len());
80 ranks.entry(id).or_insert(next);
81 }
82 Self(ranks)
83 }
84
85 fn rank<K: IdKind>(&self, id: &Id<K>) -> Rank {
86 self.0.get(&id.uuid()).copied().unwrap_or(Rank::UNCREATED)
87 }
88}
89
90fn minted(op: &OpCodes) -> Option<Uuid> {
94 fn born<I, U, K: IdKind>(id: &Id<K>, crud: &Crud<I, U>) -> Option<Uuid> {
95 matches!(crud, Crud::Create(_)).then(|| id.uuid())
96 }
97 match op {
98 OpCodes::Block(id, crud) => born(id, crud),
99 OpCodes::Pin(id, crud) => born(id, crud),
100 OpCodes::Route(id, crud) => born(id, crud),
101 OpCodes::RouteLabel(id, crud) => born(id, crud),
102 OpCodes::Text(id, crud) => born(id, crud),
103 OpCodes::Area(id, crud) => born(id, crud),
104 OpCodes::Image(id, crud) => born(id, crud),
105 OpCodes::Document(_) | OpCodes::Asset(..) => None,
106 }
107}
108
109pub fn project(repo: &Repo) -> schema::Document {
116 let doc = repo.document();
117 project_with(doc, CreationOrder::of(repo.log()))
118}
119
120pub fn to_json(repo: &Repo) -> String {
123 project(repo).to_json()
124}
125
126fn project_with(doc: &Document, created: CreationOrder) -> schema::Document {
127 let order = block_order(doc, &created);
128 let pins = pins_by_owner(doc, &created);
129 let names = Names {
130 created,
131 blocks: order
132 .iter()
133 .enumerate()
134 .map(|(i, &(id, _))| (id, format!("b{i}")))
135 .collect(),
136 pins: pins
137 .iter()
138 .flat_map(|(&owner, pins)| {
139 pins.iter()
140 .enumerate()
141 .map(move |(i, &(id, _))| (id, (owner, format!("p{}", i + 1))))
142 })
143 .collect(),
144 };
145
146 let mut assets = AssetIds::default();
147 let blocks: Vec<schema::Block> = order
148 .iter()
149 .map(|&entry| {
150 project_block(
151 doc,
152 entry,
153 &names,
154 pins.get(&entry.0).map_or(&[][..], Vec::as_slice),
155 &mut assets,
156 )
157 })
158 .collect();
159
160 let root = project_scope(doc, Scope::Root, &names, &mut assets);
163
164 let name = doc.title_block().name.as_ref();
165 let top = names
166 .blocks
167 .get(doc.title_block().top.as_ref())
168 .or_else(|| order.first().and_then(|(id, _)| names.blocks.get(id)))
169 .cloned()
170 .unwrap_or_default();
171 schema::Document {
172 version: schema::CURRENT_VERSION,
173 name: (!name.is_empty()).then(|| name.clone()),
174 top,
175 blocks,
176 routes: root.routes,
177 texts: root.texts,
178 areas: root.areas,
179 images: root.images,
180 assets: assets.assets,
181 }
182}
183
184fn position_key(rect: &GridRect, id: BlockId) -> (i32, i32, BlockId) {
189 (rect.top_left.y, rect.top_left.x, id)
190}
191
192fn block_order<'a>(doc: &'a Document, created: &CreationOrder) -> Vec<(BlockId, &'a Block)> {
195 let mut live: Vec<(BlockId, &Block)> = doc
196 .blocks()
197 .filter(|(_, live)| live.is_alive())
198 .map(|(id, live)| (id, live.as_ref()))
199 .collect();
200 live.sort_by_key(|&(id, block)| (created.rank(&id), position_key(block.rect.as_ref(), id)));
201 live
202}
203
204fn pins_by_owner<'a>(
205 doc: &'a Document,
206 created: &CreationOrder,
207) -> HashMap<BlockId, Vec<(PinId, &'a Pin)>> {
208 let mut pins: HashMap<BlockId, Vec<(PinId, &Pin)>> = HashMap::default();
209 for (id, live) in doc.pins().filter(|(_, live)| live.is_alive()) {
210 let pin = live.as_ref();
211 pins.entry(*pin.owner.as_ref()).or_default().push((id, pin));
212 }
213 for owned in pins.values_mut() {
214 owned.sort_by_key(|(id, pin)| {
220 let slot = *pin.slot.as_ref();
221 let rect = *pin.rect.as_ref();
222 (
223 created.rank(id),
224 match slot.side {
225 PinSide::West => 0_u8,
226 PinSide::East => 1,
227 },
228 slot.offset,
229 rect.top_left.y,
230 rect.top_left.x,
231 pin.name.as_ref().clone(),
232 *id,
233 )
234 });
235 }
236 pins
237}
238
239struct ProjectedContents {
243 routes: Vec<schema::Route>,
244 texts: Vec<schema::Text>,
245 areas: Vec<schema::Area>,
246 images: Vec<schema::Image>,
247}
248
249fn project_scope(
250 doc: &Document,
251 scope: Scope,
252 names: &Names,
253 assets: &mut AssetIds,
254) -> ProjectedContents {
255 let owner = scope.wire_id();
256
257 let mut texts: Vec<(Rank, GridPoint, schema::Text)> = doc
258 .texts()
259 .filter(|(_, live)| live.is_alive())
260 .map(|(text_id, live)| (text_id, live.as_ref()))
261 .filter(|(_, text)| *text.owner.as_ref() == owner)
262 .map(|(text_id, text)| {
263 let pos = *text.pos.as_ref();
264 (
265 names.rank(&text_id),
266 pos,
267 schema::Text {
268 text: text.text.as_ref().clone(),
269 x: pos.x,
270 y: pos.y,
271 role: accent_from_role(*text.role.as_ref()),
272 },
273 )
274 })
275 .collect();
276 texts.sort_by(|(ra, a, ta), (rb, b, tb)| {
277 (ra, a.y, a.x, &ta.text).cmp(&(rb, b.y, b.x, &tb.text))
278 });
279
280 let mut areas: Vec<(Rank, schema::Area)> = doc
281 .areas()
282 .filter(|(_, live)| live.is_alive())
283 .map(|(area_id, live)| (area_id, live.as_ref()))
284 .filter(|(_, area)| *area.owner.as_ref() == owner)
285 .map(|(area_id, area)| {
286 let rect = *area.rect.as_ref();
287 (
288 names.rank(&area_id),
289 schema::Area {
290 x: rect.top_left.x,
291 y: rect.top_left.y,
292 w: rect.size.w,
293 h: rect.size.h,
294 role: accent_from_role(*area.role.as_ref()),
295 title: project_label(&area.title, LabelSide::Bottom),
296 },
297 )
298 })
299 .collect();
300 areas.sort_by_key(|(rank, c)| (*rank, c.y, c.x, c.w, c.h));
301
302 let mut images: Vec<(Rank, schema::Image)> = doc
303 .images()
304 .filter(|(_, live)| live.is_alive())
305 .map(|(image_id, live)| (image_id, live.as_ref()))
306 .filter(|(_, image)| *image.owner.as_ref() == owner)
307 .filter_map(|(image_id, image)| {
308 let asset = assets.id_for(doc, *image.asset.as_ref())?;
309 Some((
310 names.rank(&image_id),
311 placement(asset, *image.rect.as_ref()),
312 ))
313 })
314 .collect();
315 images.sort_by(|(ra, a), (rb, b)| {
316 ra.cmp(rb)
317 .then(a.y.total_cmp(&b.y))
318 .then(a.x.total_cmp(&b.x))
319 .then(a.asset.cmp(&b.asset))
320 });
321
322 ProjectedContents {
323 routes: project_routes(doc, owner, names),
324 texts: texts.into_iter().map(|(_, _, text)| text).collect(),
325 areas: areas.into_iter().map(|(_, area)| area).collect(),
326 images: images.into_iter().map(|(_, image)| image).collect(),
327 }
328}
329
330fn project_block(
331 doc: &Document,
332 (id, block): (BlockId, &Block),
333 names: &Names,
334 pins: &[(PinId, &Pin)],
335 assets: &mut AssetIds,
336) -> schema::Block {
337 let rect = *block.rect.as_ref();
338
339 let mut children: Vec<(GridRect, BlockId)> = doc
340 .blocks()
341 .filter(|(_, live)| live.is_alive())
342 .filter(|(_, live)| *live.as_ref().parent.as_ref() == id)
343 .map(|(kid, live)| (*live.as_ref().rect.as_ref(), kid))
344 .collect();
345 children.sort_by_key(|&(rect, kid)| (names.rank(&kid), position_key(&rect, kid)));
346
347 let contents = project_scope(doc, Scope::Block(id), names, assets);
348
349 let icon = block.icon.as_ref();
350 let icon = (*icon != Icon::default())
351 .then(|| Some(placement(assets.id_for(doc, icon.asset)?, icon.rect)))
352 .flatten();
353
354 schema::Block {
355 id: names.blocks[&id].clone(),
356 x: rect.top_left.x,
357 y: rect.top_left.y,
358 w: rect.size.w,
359 h: rect.size.h,
360 role: accent_from_role(*block.role.as_ref()),
361 locked: *block.locked.as_ref(),
362 title: project_label(&block.title, LabelSide::Bottom),
363 type_label: project_label(&block.type_label, LabelSide::Top),
364 pins: pins
365 .iter()
366 .map(|&(pin_id, pin)| project_pin(pin_id, pin, names))
367 .collect(),
368 routes: contents.routes,
369 texts: contents.texts,
370 areas: contents.areas,
371 images: contents.images,
372 icon,
373 children: children
374 .into_iter()
375 .map(|(_, kid)| names.blocks[&kid].clone())
376 .collect(),
377 }
378}
379
380fn project_pin(id: PinId, pin: &Pin, names: &Names) -> schema::Pin {
381 let slot = *pin.slot.as_ref();
382 let rect = *pin.rect.as_ref();
383 schema::Pin {
384 id: names.pins[&id].1.clone(),
385 name: pin.name.as_ref().clone(),
386 type_label: pin.type_name.as_ref().clone(),
387 tag: pin.tag.as_ref().clone(),
388 tag_hidden: *pin.tag_hidden.as_ref(),
389 loc: Some(format_loc(schema_pin_side(slot.side), slot.offset)),
390 x: Some(rect.top_left.x),
391 y: Some(rect.top_left.y),
392 w: Some(rect.size.w),
393 dir: schema_pin_dir(*pin.dir.as_ref()),
394 pin_accent: None,
397 port_accent: accent_from_role(*pin.port_accent.as_ref()),
398 port_pin_accent: None,
399 fliplr: *pin.flip_lr.as_ref(),
400 }
401}
402
403fn project_routes(doc: &Document, owner: BlockId, names: &Names) -> Vec<schema::Route> {
404 let spell = |pin: PinId| -> Option<String> {
408 let (pin_owner, name) = names.pins.get(&pin)?;
409 let block = names.blocks.get(pin_owner)?;
410 Some(if *pin_owner == owner {
411 name.clone()
412 } else {
413 format!("{block}:{name}")
414 })
415 };
416 let mut labels: HashMap<RouteId, Vec<f32>> = HashMap::default();
417 for (_, live) in doc.route_labels().filter(|(_, live)| live.is_alive()) {
418 let label = live.as_ref();
419 labels
420 .entry(*label.owner.as_ref())
421 .or_default()
422 .push((*label.pos.as_ref()).into());
423 }
424
425 let mut routes: Vec<(Rank, schema::Route)> = doc
426 .routes()
427 .filter(|(_, live)| live.is_alive())
428 .filter(|(_, live)| *live.as_ref().owner.as_ref() == owner)
429 .filter_map(|(id, live)| {
430 let route = live.as_ref();
431 let (Some(from), Some(to)) = (spell(route.from), spell(route.to)) else {
432 tracing::warn!("not exporting a route with an unresolvable endpoint");
435 return None;
436 };
437 let mut positions = labels.remove(&id).unwrap_or_default();
438 positions.sort_by(|a, b| a.total_cmp(b));
439 Some((
440 names.rank(&id),
441 schema::Route {
442 name: route.name.as_ref().clone(),
443 from,
444 to,
445 role: accent_from_role(*route.role.as_ref()),
446 waypoints: route
447 .waypoints
448 .as_ref()
449 .iter()
450 .map(|w| schema::Waypoint {
451 x: w.pos.x,
452 y: w.pos.y,
453 locked: w.locked,
454 })
455 .collect(),
456 labels: positions,
457 },
458 ))
459 })
460 .collect();
461 routes.sort_by(|(ra, a), (rb, b)| {
462 (ra, &a.from, &a.to, &a.name).cmp(&(rb, &b.from, &b.to, &b.name))
463 });
464 routes.into_iter().map(|(_, route)| route).collect()
465}
466
467fn project_label(label: &Label, fallback: LabelSide) -> Option<schema::Label> {
472 let name = label.name.as_ref();
473 let side = *label.side.as_ref();
474 let offset: f32 = (*label.offset.as_ref()).into();
475 let hidden = *label.hidden.as_ref();
476 if name.is_empty() && side == fallback && offset == 0.0 && !hidden {
477 return None;
478 }
479 Some(schema::Label {
480 name: name.clone(),
481 side: (side != fallback).then(|| schema_label_side(side)),
482 offset,
483 hidden,
484 })
485}
486
487fn placement(asset: String, rect: blockworx_doc::geometry::ScreenRect) -> schema::Image {
488 let rect = artwork_rect(rect);
489 schema::Image {
490 asset,
491 x: rect.min.x,
492 y: rect.min.y,
493 w: rect.width(),
494 h: rect.height(),
495 }
496}
497
498#[derive(Default)]
503struct AssetIds {
504 ids: Vec<(blockworx_doc::hash::AssetHash, String)>,
505 assets: Vec<schema::Asset>,
506}
507
508impl AssetIds {
509 fn id_for(&mut self, doc: &Document, hash: blockworx_doc::hash::AssetHash) -> Option<String> {
513 if let Some((_, id)) = self.ids.iter().find(|(seen, _)| *seen == hash) {
514 return Some(id.clone());
515 }
516 let Some(asset) = doc.asset(&hash) else {
517 tracing::warn!("not exporting a placement of {hash}: the document holds no payload");
518 return None;
519 };
520 let (bytes, ext, image) = match asset {
521 Asset::Svg(bytes) => (
522 &**bytes,
523 "svg",
524 schema::ImageData::Svg(String::from_utf8_lossy(bytes).into_owned()),
525 ),
526 Asset::Png(bytes) => (
527 &**bytes,
528 "png",
529 schema::ImageData::Png(base64::engine::general_purpose::STANDARD.encode(bytes)),
530 ),
531 };
532 let id = format!("{}.{ext}", &blake3::hash(bytes).to_hex()[..16]);
533 self.assets.push(schema::Asset {
534 id: id.clone(),
535 image,
536 });
537 self.ids.push((hash, id.clone()));
538 Some(id)
539 }
540}
541
542#[cfg(test)]
543pub(crate) mod tests {
544 use super::*;
545 use crate::schema::lower::lower;
546
547 const RICH: &str = crate::schema::lower::tests::RICH;
549
550 pub(crate) fn folded_repo(doc: &schema::Document, label: &str) -> Repo {
555 let lowered = lower(doc, label);
556 Repo::folding(&lowered.commits).expect("the lowered commits fold")
557 }
558
559 fn folded(src: &str) -> Repo {
560 let parsed = schema::Document::parse_json(src, "project").expect("the fixture parses");
561 folded_repo(&parsed, "project")
562 }
563
564 fn block_named<'a>(doc: &'a schema::Document, title: &str) -> &'a schema::Block {
565 doc.blocks
566 .iter()
567 .find(|b| b.title.as_ref().is_some_and(|t| t.name == title))
568 .unwrap_or_else(|| panic!("no projected block titled {title:?}"))
569 }
570
571 #[test]
575 fn the_rich_fixture_projects_with_its_fields_intact() {
576 let projected = project(&folded(RICH));
577
578 assert_eq!(projected.version, schema::CURRENT_VERSION);
579 assert_eq!(projected.name.as_deref(), Some("bridge"));
580
581 let sheet = block_named(&projected, "sheet");
582 assert_eq!(projected.top, sheet.id, "the top pointer survives");
583 assert_eq!((sheet.x, sheet.y, sheet.w, sheet.h), (0, 0, 30, 20));
584 let title = sheet.title.as_ref().expect("the sheet keeps its title");
585 assert_eq!(
586 (title.side, title.offset, title.hidden),
587 (Some(crate::schema::enums::LabelSide::Center), 1.5, true),
588 );
589
590 let core = block_named(&projected, "core");
591 assert_eq!(sheet.children, vec![core.id.clone()], "nesting survives");
592 assert_eq!(core.role, Some(3));
593 assert!(core.locked);
594 assert_eq!(
595 core.type_label.as_ref().map(|t| (t.name.as_str(), t.side)),
596 Some(("Add", None)),
597 "a type label at its per-kind default side omits the side",
598 );
599
600 let pin = |block: &schema::Block, name: &str| -> schema::Pin {
601 block
602 .pins
603 .iter()
604 .find(|p| p.name == name)
605 .unwrap_or_else(|| panic!("no projected pin named {name:?}"))
606 .clone()
607 };
608 let input = pin(sheet, "in");
609 assert_eq!(input.loc.as_deref(), Some("w0"));
610 assert_eq!(input.dir, Some(crate::schema::enums::PinType::Input));
611 assert_eq!((input.tag.as_str(), input.tag_hidden), ("A", true));
612 assert_eq!(input.port_accent, Some(2));
613 assert!(input.fliplr);
614 assert!(
615 input.x.is_some() && input.w.is_some(),
616 "an omitted port body exports explicitly once reconstructed",
617 );
618 let output = pin(sheet, "out");
619 assert_eq!((output.x, output.y, output.w), (Some(4), Some(6), Some(5)));
620 assert_eq!(output.type_label, "bit");
621 assert_eq!(
622 pin(core, "a").dir,
623 None,
624 "an in-out pin omits `dir`, the spelling that reads back as in-out",
625 );
626
627 let route = {
628 assert_eq!(sheet.routes.len(), 1, "the dangling route stayed dropped");
629 &sheet.routes[0]
630 };
631 assert_eq!(route.name, "net");
632 assert_eq!(route.role, Some(1));
633 assert_eq!(
634 route.from,
635 pin(sheet, "in").id,
636 "an own-block anchor is bare"
637 );
638 assert_eq!(
639 route.to,
640 format!("{}:{}", core.id, pin(core, "a").id),
641 "a child anchor is qualified",
642 );
643 assert_eq!(
644 route.waypoints,
645 vec![
646 schema::Waypoint {
647 x: 12,
648 y: 35,
649 locked: false
650 },
651 schema::Waypoint {
652 x: 2,
653 y: 41,
654 locked: true
655 },
656 ],
657 );
658 assert_eq!(route.labels, vec![7.5]);
659
660 assert_eq!(sheet.texts.len(), 1);
661 assert_eq!(
662 (sheet.texts[0].x, sheet.texts[0].y, sheet.texts[0].role),
663 (10, 5, Some(1)),
664 );
665 assert_eq!(sheet.areas.len(), 1);
666 assert_eq!(
667 sheet.areas[0].title.as_ref().map(|t| t.name.as_str()),
668 Some("group"),
669 );
670
671 assert_eq!(projected.assets.len(), 1, "one payload for two placements");
672 let asset = &projected.assets[0];
673 assert_eq!(
674 asset.id,
675 format!("{}.svg", &blake3::hash(b"<svg/>").to_hex()[..16]),
676 "the id is content-derived, replacing the file's own spelling",
677 );
678 assert_eq!(sheet.images.len(), 1);
679 assert_eq!(sheet.images[0].asset, asset.id);
680 assert_eq!(
681 sheet.icon.as_ref().map(|icon| icon.asset.as_str()),
682 Some(asset.id.as_str()),
683 );
684 assert_ne!(
685 (sheet.images[0].x, sheet.images[0].y),
686 (
687 sheet.icon.as_ref().unwrap().x,
688 sheet.icon.as_ref().unwrap().y
689 ),
690 "the two placements keep their own boxes",
691 );
692 }
693
694 #[test]
698 fn parse_lower_fold_project_is_identity_on_the_projection() {
699 let projected = project(&folded(RICH));
700 let reparsed = schema::Document::parse_json(&projected.to_json(), "projection")
701 .expect("the projection parses as JSON");
702 assert_eq!(project(&folded_repo(&reparsed, "project")), projected);
703 }
704
705 const OUT_OF_ORDER: &str = r#"
708{
709 "version": 2,
710 "top": "b0",
711 "blocks": [
712 {
713 "id": "b0",
714 "x": 0,
715 "y": 0,
716 "w": 40,
717 "h": 40,
718 "title": {
719 "name": "sheet"
720 },
721 "children": [
722 "b1",
723 "b2"
724 ]
725 },
726 {
727 "id": "b1",
728 "x": 0,
729 "y": 20,
730 "w": 8,
731 "h": 5,
732 "title": {
733 "name": "lower"
734 }
735 },
736 {
737 "id": "b2",
738 "x": 0,
739 "y": 5,
740 "w": 8,
741 "h": 5,
742 "title": {
743 "name": "upper"
744 }
745 }
746 ]
747}
748"#;
749
750 #[test]
756 fn a_move_changes_one_blocks_coordinates_and_no_ones_name() {
757 let mut repo = folded(OUT_OF_ORDER);
758 let named = |doc: &schema::Document, title: &str| -> schema::Block {
759 doc.blocks
760 .iter()
761 .find(|b| b.title.as_ref().is_some_and(|t| t.name == title))
762 .unwrap_or_else(|| panic!("no projected block titled {title:?}"))
763 .clone()
764 };
765
766 let before = project(&repo);
767 assert_eq!(
768 (named(&before, "lower").id, named(&before, "upper").id),
769 ("b1".to_owned(), "b2".to_owned()),
770 "names follow the file's order",
771 );
772 assert!(
773 named(&before, "upper").y < named(&before, "lower").y,
774 "precondition: the file order already disagrees with the positional \
775 one, so `upper` would have been b1 under the numbering this replaced",
776 );
777
778 let lower_id = repo
779 .document()
780 .blocks()
781 .find(|(_, live)| live.as_ref().title.name.as_ref() == "lower")
782 .map(|(id, _)| id)
783 .expect("the lower block is in the document");
784 repo.submit(Commit::new(
785 "Moved a block".to_owned(),
786 vec![OpCodes::Block(
787 lower_id,
788 Crud::Update(blockworx_doc::block_model::BlockUpdate::Rect(GridRect {
789 top_left: GridPoint { x: 0, y: 1 },
790 size: blockworx_doc::geometry::GridSize { w: 8, h: 5 },
791 })),
792 )],
793 ))
794 .expect("the move folds");
795
796 let after = project(&repo);
797 assert!(
798 named(&after, "lower").y < named(&after, "upper").y,
799 "precondition: the move crossed the two blocks, so the positional \
800 numbering would have swapped them a second time",
801 );
802 assert_eq!(
803 after.blocks.iter().map(|b| &b.id).collect::<Vec<_>>(),
804 before.blocks.iter().map(|b| &b.id).collect::<Vec<_>>(),
805 "the numbering must not follow the move",
806 );
807 let differing: Vec<&str> = before
808 .blocks
809 .iter()
810 .zip(&after.blocks)
811 .filter(|(was, is)| was != is)
812 .map(|(was, _)| was.id.as_str())
813 .collect();
814 assert_eq!(differing, vec!["b1"], "only the moved block differs");
815 assert_eq!(named(&after, "lower").y, 1);
816 }
817
818 #[test]
821 fn an_empty_document_projects_empty() {
822 let projected = project(&Repo::default());
823 assert_eq!(projected.blocks, vec![]);
824 assert_eq!(projected.top, "");
825 assert_eq!(projected.name, None);
826 }
827}