1use ahash::HashMap;
16use blockworx_doc::{
17 block_model::{
18 AreaInit, Asset, BlockInit, Icon, ImageInit, LabelInit, PinInit, RouteInit, RouteLabelInit,
19 TextInit,
20 },
21 commit::{Commit, CommitBuilder},
22 document::TitleBlockUpdate,
23 geometry::{FracVal, GridPoint, GridRect, GridSize, PinSlot, Waypoint},
24 id::{BlockId, PinId},
25 opcode::{Crud, OpCodes},
26 values::LabelSide,
27};
28
29use base64::Engine as _;
30
31use crate::edit::FreshIds;
32use crate::edit::create::default_port_rect;
33use crate::edit::lower::{label_side, pin_dir, pin_side, role_from_accent, screen_rect};
34use crate::schema::SchemaError;
35use crate::schema::loc::parse_loc;
36use crate::schema::model as schema;
37use crate::shape::port::PORT_HEIGHT;
38
39pub fn lower(doc: &schema::Document, label: &str) -> Lowered {
43 let mut builder = CommitBuilder::new(label);
44 let mut lowering = Lowering::new(doc);
45 lowering.emit(doc, &mut builder);
46 Lowered {
47 commits: builder.seal().into_iter().collect(),
48 ids: SourceIds {
49 blocks: lowering.blocks,
50 pins: lowering
51 .pins
52 .into_iter()
53 .map(|(key, (id, _))| (key, id))
54 .collect(),
55 },
56 }
57}
58
59#[derive(Default)]
62pub struct Lowered {
63 pub commits: Vec<Commit>,
64 pub ids: SourceIds,
65}
66
67#[derive(Default)]
73pub struct SourceIds {
74 blocks: HashMap<String, BlockId>,
75 pins: HashMap<String, PinId>,
76}
77
78impl SourceIds {
79 pub fn block(&self, id: &str) -> Option<BlockId> {
80 self.blocks.get(id).copied()
81 }
82
83 pub fn pin(&self, block: &str, pin: &str) -> Option<PinId> {
84 self.pins.get(&anchor_key(block, pin)).copied()
85 }
86}
87
88struct Lowering {
93 ids: FreshIds,
94 blocks: HashMap<String, BlockId>,
95 pins: HashMap<String, (PinId, PinSlot)>,
98 assets: HashMap<String, Asset>,
99}
100
101impl Lowering {
102 fn new(doc: &schema::Document) -> Self {
103 let mut ids = FreshIds::Random;
104 let mut blocks = HashMap::default();
105 let mut pins = HashMap::default();
106 for block in &doc.blocks {
107 blocks.insert(block.id.clone(), ids.mint());
108 for pin in &block.pins {
109 if let Some(slot) = slot_of(block, pin) {
110 pins.insert(anchor_key(&block.id, &pin.id), (ids.mint(), slot));
111 }
112 }
113 }
114 Self {
115 ids,
116 blocks,
117 pins,
118 assets: decode_assets(doc),
119 }
120 }
121
122 fn emit(&mut self, doc: &schema::Document, builder: &mut CommitBuilder) {
126 if let Some(name) = &doc.name {
127 builder.push(OpCodes::Document(TitleBlockUpdate::Name(name.clone())));
128 }
129 for id in doc.referenced_assets() {
130 if let Some(asset) = self.assets.get(id) {
131 builder.push(OpCodes::Asset(asset.hash(), asset.clone()));
132 }
133 }
134 let parents = parent_map(doc);
135 for block in &doc.blocks {
136 self.push_block(block, parents.get(block.id.as_str()).copied(), builder);
137 }
138 if let Some(&top) = self.blocks.get(&doc.top) {
139 builder.push(OpCodes::Document(TitleBlockUpdate::Top(top)));
140 } else {
141 tracing::warn!("dropping the top pointer: {:?} names no block", doc.top);
142 }
143 for block in &doc.blocks {
144 self.push_pins(block, builder);
145 }
146 for block in &doc.blocks {
147 if let Some(scope) = self.scope_of(block) {
148 self.push_contents(scope, block.contents(), builder);
149 }
150 }
151 self.push_contents(LoweredScope::Root, doc.root_contents(), builder);
152 }
153
154 fn scope_of<'a>(&self, block: &'a schema::Block) -> Option<LoweredScope<'a>> {
155 Some(LoweredScope::Block {
156 owner: *self.blocks.get(&block.id)?,
157 name: &block.id,
158 })
159 }
160
161 fn push_block(
162 &mut self,
163 block: &schema::Block,
164 parent: Option<&str>,
165 builder: &mut CommitBuilder,
166 ) {
167 let Some(scope) = self.scope_of(block) else {
168 return;
169 };
170 let id = scope.owner();
171 let parent = parent
172 .and_then(|parent| self.blocks.get(parent))
173 .copied()
174 .unwrap_or(BlockId::NULL);
175 builder.push(OpCodes::Block(
176 id,
177 Crud::Create(BlockInit {
178 parent,
179 rect: cells(block.x, block.y, block.w, block.h),
180 locked: block.locked,
181 role: role_from_accent(block.role),
182 title: label_init(block.title.as_ref(), LabelSide::Bottom),
183 type_label: label_init(block.type_label.as_ref(), LabelSide::Top),
184 icon: block
185 .icon
186 .as_ref()
187 .and_then(|icon| self.artwork(scope, icon))
188 .map_or_else(Icon::default, |(asset, rect)| Icon { asset, rect }),
189 }),
190 ));
191 }
192
193 fn push_pins(&mut self, block: &schema::Block, builder: &mut CommitBuilder) {
194 let Some(&owner) = self.blocks.get(&block.id) else {
195 return;
196 };
197 let mut bodies: Vec<GridRect> = block.pins.iter().map(stored_port_rect).collect();
202 let interior = cells(block.x, block.y, block.w, block.h);
203 for (index, pin) in block.pins.iter().enumerate() {
204 let Some(&(id, slot)) = self.pins.get(&anchor_key(&block.id, &pin.id)) else {
205 continue;
206 };
207 if bodies[index].size.w == 0 {
208 bodies[index] = default_port_rect(interior, &bodies, &pin.name, slot);
209 }
210 builder.push(OpCodes::Pin(
211 id,
212 Crud::Create(PinInit {
213 owner,
214 name: pin.name.clone(),
215 type_name: pin.type_label.clone(),
216 tag: pin.tag.clone(),
217 tag_hidden: pin.tag_hidden,
218 rect: bodies[index],
219 slot,
220 dir: pin_dir(pin.dir),
221 port_accent: role_from_accent(pin.port_accent),
225 flip_lr: pin.fliplr,
226 }),
227 ));
228 }
229 }
230
231 fn push_contents(
234 &mut self,
235 scope: LoweredScope<'_>,
236 contents: schema::ScopeContents<'_>,
237 builder: &mut CommitBuilder,
238 ) {
239 self.push_routes(scope, contents.routes, builder);
240 self.push_annotations(scope, contents, builder);
241 }
242
243 fn push_routes(
244 &mut self,
245 scope: LoweredScope<'_>,
246 routes: &[schema::Route],
247 builder: &mut CommitBuilder,
248 ) {
249 let owner = scope.owner();
250 for route in routes {
251 let (Some(from), Some(to)) = (
252 self.endpoint(scope, &route.from),
253 self.endpoint(scope, &route.to),
254 ) else {
255 tracing::warn!(
256 "dropping route {:?} on {scope}: {:?} → {:?} names a pin the document does not hold",
257 route.name,
258 route.from,
259 route.to,
260 );
261 continue;
262 };
263 let id = self.ids.mint();
264 builder.push(OpCodes::Route(
265 id,
266 Crud::Create(RouteInit {
267 owner,
268 name: route.name.clone(),
269 from,
270 to,
271 role: role_from_accent(route.role),
272 waypoints: route
273 .waypoints
274 .iter()
275 .map(|w| Waypoint {
276 pos: GridPoint { x: w.x, y: w.y },
277 locked: w.locked,
278 })
279 .collect(),
280 }),
281 ));
282 for at in &route.labels {
283 builder.push(OpCodes::RouteLabel(
284 self.ids.mint(),
285 Crud::Create(RouteLabelInit {
286 owner: id,
287 pos: FracVal::from(*at),
288 }),
289 ));
290 }
291 }
292 }
293
294 fn push_annotations(
295 &mut self,
296 scope: LoweredScope<'_>,
297 contents: schema::ScopeContents<'_>,
298 builder: &mut CommitBuilder,
299 ) {
300 let owner = scope.owner();
301 for text in contents.texts {
302 builder.push(OpCodes::Text(
303 self.ids.mint(),
304 Crud::Create(TextInit {
305 owner,
306 text: text.text.clone(),
307 pos: GridPoint {
308 x: text.x,
309 y: text.y,
310 },
311 role: role_from_accent(text.role),
312 }),
313 ));
314 }
315 for area in contents.areas {
316 builder.push(OpCodes::Area(
317 self.ids.mint(),
318 Crud::Create(AreaInit {
319 owner,
320 rect: cells(area.x, area.y, area.w, area.h),
321 role: role_from_accent(area.role),
322 title: label_init(area.title.as_ref(), LabelSide::Bottom),
323 }),
324 ));
325 }
326 for image in contents.images {
327 let Some((asset, rect)) = self.artwork(scope, image) else {
328 continue;
329 };
330 builder.push(OpCodes::Image(
331 self.ids.mint(),
332 Crud::Create(ImageInit { owner, asset, rect }),
333 ));
334 }
335 }
336
337 fn endpoint(&self, scope: LoweredScope<'_>, anchor: &str) -> Option<PinId> {
338 self.pins.get(&scope.anchor_key(anchor)?).map(|&(id, _)| id)
339 }
340
341 fn artwork(
345 &self,
346 scope: LoweredScope<'_>,
347 image: &schema::Image,
348 ) -> Option<(
349 blockworx_doc::hash::AssetHash,
350 blockworx_doc::geometry::ScreenRect,
351 )> {
352 let Some(asset) = self.assets.get(&image.asset) else {
353 tracing::warn!(
354 "dropping artwork on {scope}: no asset {:?} in the document",
355 image.asset,
356 );
357 return None;
358 };
359 Some((
360 asset.hash(),
361 screen_rect(egui::Rect::from_min_size(
362 egui::pos2(image.x, image.y),
363 egui::vec2(image.w, image.h),
364 )),
365 ))
366 }
367}
368
369#[derive(Clone, Copy)]
372enum LoweredScope<'a> {
373 Root,
374 Block { owner: BlockId, name: &'a str },
375}
376
377impl LoweredScope<'_> {
378 fn owner(self) -> BlockId {
379 match self {
380 LoweredScope::Root => BlockId::NULL,
381 LoweredScope::Block { owner, .. } => owner,
382 }
383 }
384
385 fn anchor_key(self, anchor: &str) -> Option<String> {
390 match self {
391 LoweredScope::Root => anchor.contains(':').then(|| anchor.to_owned()),
392 LoweredScope::Block { name, .. } => Some(anchor_key(name, anchor)),
393 }
394 }
395}
396
397impl std::fmt::Display for LoweredScope<'_> {
398 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
399 match self {
400 LoweredScope::Root => f.write_str("the document root"),
401 LoweredScope::Block { name, .. } => write!(f, "block {name:?}"),
402 }
403 }
404}
405
406fn parent_map(doc: &schema::Document) -> HashMap<&str, &str> {
409 let mut parents = HashMap::default();
410 for block in &doc.blocks {
411 for child in &block.children {
412 parents.insert(child.as_str(), block.id.as_str());
413 }
414 }
415 parents
416}
417
418fn anchor_key(owner: &str, anchor: &str) -> String {
421 if anchor.contains(':') {
422 anchor.to_string()
423 } else {
424 format!("{owner}:{anchor}")
425 }
426}
427
428fn cells(x: i32, y: i32, w: u32, h: u32) -> GridRect {
429 GridRect {
430 top_left: GridPoint { x, y },
431 size: GridSize { w, h },
432 }
433}
434
435fn slot_of(block: &schema::Block, pin: &schema::Pin) -> Option<PinSlot> {
440 let Some(loc) = pin.loc.as_deref() else {
441 tracing::warn!(
442 "dropping pin {:?} on block {:?}: no `loc`",
443 pin.id,
444 block.id
445 );
446 return None;
447 };
448 match parse_loc(loc) {
449 Ok((side, offset)) => Some(PinSlot {
450 side: pin_side(side),
451 offset,
452 }),
453 Err(reason) => {
454 tracing::warn!(
455 "dropping pin {:?} on block {:?}: {reason}",
456 pin.id,
457 block.id
458 );
459 None
460 }
461 }
462}
463
464fn stored_port_rect(pin: &schema::Pin) -> GridRect {
468 match (pin.x, pin.y, pin.w) {
469 (Some(x), Some(y), Some(w)) => GridRect {
470 top_left: GridPoint { x, y },
471 size: GridSize { w, h: PORT_HEIGHT },
472 },
473 _ => GridRect::default(),
474 }
475}
476
477fn label_init(label: Option<&schema::Label>, fallback: LabelSide) -> LabelInit {
481 match label {
482 None => LabelInit {
483 name: String::new(),
484 side: fallback,
485 offset: FracVal::default(),
486 hidden: false,
487 },
488 Some(label) => LabelInit {
489 name: label.name.clone(),
490 side: label.side.map_or(fallback, label_side),
491 offset: FracVal::from(label.offset),
492 hidden: label.hidden,
493 },
494 }
495}
496
497fn asset_to_bytes(asset: &schema::Asset) -> Result<Vec<u8>, SchemaError> {
501 match &asset.image {
502 schema::ImageData::Svg(src) => Ok(src.clone().into_bytes()),
503 schema::ImageData::Png(b64) => base64::engine::general_purpose::STANDARD
504 .decode(b64.as_bytes())
505 .map_err(|e| SchemaError::AssetPng {
506 asset: asset.id.clone(),
507 reason: e.to_string(),
508 }),
509 }
510}
511
512fn decode_assets(doc: &schema::Document) -> HashMap<String, Asset> {
516 let mut assets = HashMap::default();
517 for asset in &doc.assets {
518 match asset_to_bytes(asset) {
519 Ok(bytes) => {
520 let payload = match asset.image {
521 schema::ImageData::Svg(_) => Asset::Svg(bytes.into()),
522 schema::ImageData::Png(_) => Asset::Png(bytes.into()),
523 };
524 assets.insert(asset.id.clone(), payload);
525 }
526 Err(refusal) => tracing::warn!("dropping asset {:?}: {refusal}", asset.id),
527 }
528 }
529 assets
530}
531
532#[cfg(test)]
533pub(crate) mod tests {
534 use super::*;
535 use blockworx_doc::{
536 block_model::{Area, Block, Image, Live, Pin, Route, RouteLabel, Text},
537 document::Document,
538 values::{PinDir, PinSide, Role},
539 };
540
541 pub(crate) const RICH: &str = r#"
547{
548 "version": 2,
549 "name": "bridge",
550 "top": "b0",
551 "blocks": [
552 {
553 "id": "b0",
554 "x": 0,
555 "y": 0,
556 "w": 30,
557 "h": 20,
558 "title": {
559 "name": "sheet",
560 "side": "center",
561 "offset": 1.5,
562 "hidden": true
563 },
564 "pins": [
565 {
566 "id": "p1",
567 "name": "in",
568 "tag": "A",
569 "tag_hidden": true,
570 "loc": "w0",
571 "dir": "input",
572 "port_accent": 2,
573 "fliplr": true
574 },
575 {
576 "id": "p2",
577 "name": "out",
578 "type": "bit",
579 "loc": "e3",
580 "x": 4,
581 "y": 6,
582 "w": 5,
583 "dir": "output"
584 }
585 ],
586 "routes": [
587 {
588 "name": "net",
589 "from": "p1",
590 "to": "b1:p1",
591 "role": 1,
592 "waypoints": [
593 {
594 "x": 12,
595 "y": 35
596 },
597 {
598 "x": 2,
599 "y": 41,
600 "locked": true
601 }
602 ],
603 "labels": [
604 7.5
605 ]
606 },
607 {
608 "from": "p2",
609 "to": "b1:p9"
610 }
611 ],
612 "texts": [
613 {
614 "text": "a note",
615 "x": 10,
616 "y": 5,
617 "role": 1
618 }
619 ],
620 "areas": [
621 {
622 "x": 2,
623 "y": 3,
624 "w": 12,
625 "h": 6,
626 "role": 2,
627 "title": {
628 "name": "group"
629 }
630 }
631 ],
632 "images": [
633 {
634 "asset": "art.svg",
635 "x": 1.5,
636 "y": 2.5,
637 "w": 8.0,
638 "h": 6.0
639 }
640 ],
641 "icon": {
642 "asset": "art.svg",
643 "x": 2.0,
644 "y": 2.0,
645 "w": 6.0,
646 "h": 6.0
647 },
648 "children": [
649 "b1"
650 ]
651 },
652 {
653 "id": "b1",
654 "x": 6,
655 "y": 6,
656 "w": 8,
657 "h": 5,
658 "role": 3,
659 "locked": true,
660 "title": {
661 "name": "core"
662 },
663 "type": {
664 "name": "Add"
665 },
666 "pins": [
667 {
668 "id": "p1",
669 "name": "a",
670 "loc": "w1"
671 }
672 ]
673 }
674 ],
675 "assets": [
676 {
677 "id": "art.svg",
678 "svg": "<svg/>"
679 }
680 ]
681}
682"#;
683
684 const EMPTY: &str = r#"{"version": 2, "top": "b0", "blocks": []}"#;
686
687 fn parsed(src: &str) -> schema::Document {
688 schema::Document::parse_json(src, "bridge").expect("the fixture parses")
689 }
690
691 fn folded(src: &str) -> Document {
693 let lowered = lower(&parsed(src), "Lowered a level");
694 assert_eq!(
695 lowered.commits.len(),
696 1,
697 "a document lowers to one labeled commit"
698 );
699 Document::default()
700 .try_apply(&lowered.commits[0])
701 .expect("the lowered commit folds")
702 }
703
704 fn block_named<'a>(doc: &'a Document, title: &str) -> &'a Block {
705 doc.blocks()
706 .filter(|(_, live)| live.is_alive())
707 .map(|(_, live)| live.as_ref())
708 .find(|block| block.title.name.as_ref() == title)
709 .unwrap_or_else(|| panic!("no block titled {title:?}"))
710 }
711
712 fn pin_named<'a>(doc: &'a Document, name: &str) -> &'a Pin {
713 doc.pins()
714 .filter(|(_, live)| live.is_alive())
715 .map(|(_, live)| live.as_ref())
716 .find(|pin| pin.name.as_ref() == name)
717 .unwrap_or_else(|| panic!("no pin named {name:?}"))
718 }
719
720 fn only_route(doc: &Document) -> &Route {
721 let routes: Vec<&Route> = doc.routes().map(|(_, live)| live.as_ref()).collect();
722 assert_eq!(routes.len(), 1, "the dangling anchor dropped its route");
723 routes[0]
724 }
725
726 fn only<'a, I, T>(mut entities: impl Iterator<Item = (I, &'a Live<T>)>) -> &'a T
727 where
728 T: 'a,
729 {
730 let first = entities.next().expect("one entity").1.as_ref();
731 assert!(entities.next().is_none(), "exactly one");
732 first
733 }
734
735 #[test]
736 fn a_block_keeps_its_geometry_role_lock_and_place_in_the_tree() {
737 let doc = folded(RICH);
738 let sheet = block_named(&doc, "sheet");
739 let core = block_named(&doc, "core");
740
741 assert_eq!(*sheet.rect.as_ref(), cells(0, 0, 30, 20));
742 assert_eq!(
743 *core.rect.as_ref(),
744 cells(6, 6, 8, 5),
745 "the file's height is off the block ladder and is copied anyway",
746 );
747 assert_eq!(
748 *core.role.as_ref(),
749 Role::Accent4,
750 "role=3 is the fourth accent"
751 );
752 assert!(*core.locked.as_ref());
753 assert_eq!(*sheet.parent.as_ref(), BlockId::NULL);
754 assert_ne!(
755 *core.parent.as_ref(),
756 BlockId::NULL,
757 "the sheet's `children` list is the child's parent link",
758 );
759 assert_eq!(
760 doc.title_block().top.as_ref(),
761 &doc.blocks()
762 .find(|(_, live)| live.as_ref().title.name.as_ref() == "sheet")
763 .expect("the sheet is in the document")
764 .0,
765 );
766 assert_eq!(doc.title_block().name.as_ref().as_str(), "bridge");
767 }
768
769 #[test]
772 fn labels_carry_their_placement_and_the_legacy_per_kind_default() {
773 let doc = folded(RICH);
774 let sheet = block_named(&doc, "sheet");
775 assert_eq!(*sheet.title.side.as_ref(), LabelSide::Center);
776 assert_eq!(*sheet.title.offset.as_ref(), FracVal::from(1.5));
777 assert!(*sheet.title.hidden.as_ref());
778
779 let core = block_named(&doc, "core");
780 assert_eq!(
781 *core.title.side.as_ref(),
782 LabelSide::Bottom,
783 "an omitted title side is `BlockLabel::default`'s",
784 );
785 assert_eq!(core.type_label.name.as_ref().as_str(), "Add");
786 assert_eq!(
787 *core.type_label.side.as_ref(),
788 LabelSide::Top,
789 "an omitted type side is `BlockLabel::upper_left`'s",
790 );
791 }
792
793 #[test]
794 fn a_pin_keeps_its_slot_direction_tag_accent_and_facing() {
795 let doc = folded(RICH);
796 let input = pin_named(&doc, "in");
797 assert_eq!(
798 *input.slot.as_ref(),
799 PinSlot {
800 side: PinSide::West,
801 offset: 0
802 },
803 );
804 assert_eq!(*input.dir.as_ref(), PinDir::Input);
805 assert_eq!(input.tag.as_ref().as_str(), "A");
806 assert!(*input.tag_hidden.as_ref());
807 assert_eq!(*input.port_accent.as_ref(), Role::Accent3);
808 assert!(*input.flip_lr.as_ref(), "fliplr travels verbatim (D3)");
809
810 let output = pin_named(&doc, "out");
811 assert_eq!(
812 *output.slot.as_ref(),
813 PinSlot {
814 side: PinSide::East,
815 offset: 3
816 },
817 );
818 assert_eq!(*output.dir.as_ref(), PinDir::Output);
819 assert_eq!(output.type_name.as_ref().as_str(), "bit");
820
821 assert_eq!(
822 *pin_named(&doc, "a").dir.as_ref(),
823 PinDir::InOut,
824 "an omitted direction is in-out, not the enum's zero",
825 );
826 }
827
828 #[test]
833 fn port_bodies_are_verbatim_or_auto_placed() {
834 let doc = folded(RICH);
835 assert_eq!(
836 *pin_named(&doc, "out").rect.as_ref(),
837 cells(4, 6, 5, PORT_HEIGHT),
838 "an explicit body is copied verbatim",
839 );
840
841 let expected_in = default_port_rect(
844 cells(0, 0, 30, 20),
845 &[GridRect::default(), cells(4, 6, 5, PORT_HEIGHT)],
846 "in",
847 PinSlot {
848 side: PinSide::West,
849 offset: 0,
850 },
851 );
852 assert_ne!(
853 expected_in.size.w, 0,
854 "precondition: the formula placed a non-empty body",
855 );
856 assert_eq!(*pin_named(&doc, "in").rect.as_ref(), expected_in);
857
858 let expected_a = default_port_rect(
860 cells(6, 6, 8, 5),
861 &[GridRect::default()],
862 "a",
863 PinSlot {
864 side: PinSide::West,
865 offset: 1,
866 },
867 );
868 assert_eq!(*pin_named(&doc, "a").rect.as_ref(), expected_a);
869 }
870
871 #[test]
872 fn a_route_keeps_its_endpoints_waypoints_and_labels() {
873 let doc = folded(RICH);
874 let route = only_route(&doc);
875 assert_eq!(route.name.as_ref().as_str(), "net");
876 assert_eq!(*route.role.as_ref(), Role::Accent2);
877 assert_eq!(
878 route.waypoints.as_ref(),
879 &vec![
880 Waypoint {
881 pos: GridPoint { x: 12, y: 35 },
882 locked: false
883 },
884 Waypoint {
885 pos: GridPoint { x: 2, y: 41 },
886 locked: true
887 },
888 ],
889 );
890
891 let ends: Vec<&Pin> = doc
892 .pins()
893 .filter(|(id, _)| *id == route.from || *id == route.to)
894 .map(|(_, live)| live.as_ref())
895 .collect();
896 assert_eq!(ends.len(), 2, "both anchor spellings resolved");
897 let named: Vec<&str> = {
898 let mut names: Vec<&str> = ends.iter().map(|pin| pin.name.as_ref().as_str()).collect();
899 names.sort_unstable();
900 names
901 };
902 assert_eq!(
903 named,
904 vec!["a", "in"],
905 "`p1` is this block's, `b1:p1` a child's"
906 );
907
908 let labels: Vec<&RouteLabel> = doc.route_labels().map(|(_, live)| live.as_ref()).collect();
909 assert_eq!(labels.len(), 1);
910 assert_eq!(*labels[0].pos.as_ref(), FracVal::from(7.5));
911 }
912
913 #[test]
914 fn annotations_and_artwork_travel_with_their_payload() {
915 let doc = folded(RICH);
916 let text: &Text = only(doc.texts());
917 assert_eq!(text.text.as_ref().as_str(), "a note");
918 assert_eq!(*text.pos.as_ref(), GridPoint { x: 10, y: 5 });
919 assert_eq!(*text.role.as_ref(), Role::Accent2);
920
921 let area: &Area = only(doc.areas());
922 assert_eq!(*area.rect.as_ref(), cells(2, 3, 12, 6));
923 assert_eq!(*area.role.as_ref(), Role::Accent3);
924 assert_eq!(area.title.name.as_ref().as_str(), "group");
925 assert_eq!(
926 *area.title.side.as_ref(),
927 LabelSide::Bottom,
928 "an area title takes the same fallback a block title does",
929 );
930
931 let image: &Image = only(doc.images());
932 let sheet = block_named(&doc, "sheet");
933 let bytes = Asset::Svg(b"<svg/>".as_slice().into());
934 assert_eq!(*image.asset.as_ref(), bytes.hash());
935 assert_eq!(sheet.icon.as_ref().asset, bytes.hash());
936 assert_ne!(
937 image.rect.as_ref(),
938 &sheet.icon.as_ref().rect,
939 "the two placements of one asset keep their own boxes",
940 );
941 assert_eq!(
942 doc.asset(&bytes.hash()),
943 Some(&bytes),
944 "the payload rode the same commit the placements did",
945 );
946 }
947
948 #[test]
951 fn a_document_with_no_content_lowers_to_no_commits() {
952 assert!(lower(&parsed(EMPTY), "Lowered a level").commits.is_empty());
953 }
954
955 #[test]
959 fn the_source_id_table_resolves_the_files_own_names() {
960 let lowered = lower(&parsed(RICH), "Lowered a level");
961 let doc = Document::default()
962 .try_apply(&lowered.commits[0])
963 .expect("the lowered commit folds");
964
965 let core = lowered.ids.block("b1").expect("the file names b1");
966 assert_eq!(
967 doc.block(&core)
968 .expect("b1 is in the document")
969 .as_ref()
970 .title
971 .name
972 .as_ref(),
973 "core",
974 );
975 let pin = lowered.ids.pin("b1", "p1").expect("the file names b1:p1");
976 assert_eq!(
977 doc.pin(&pin)
978 .expect("b1:p1 is in the document")
979 .as_ref()
980 .name
981 .as_ref(),
982 "a",
983 );
984 assert_eq!(
985 lowered.ids.pin("b0", "b1:p1"),
986 Some(pin),
987 "a child's pin resolves through the anchor spelling a route would use",
988 );
989 assert_eq!(lowered.ids.block("b9"), None);
990 assert_eq!(lowered.ids.pin("b1", "p9"), None);
991 }
992}