Skip to main content

blockworx/schema/
lower.rs

1//! The import direction (editor-swap decision D8): a parsed `schema::model`
2//! document, as the commit log that builds it. [`super::project`] is the
3//! inverse, back from the folded document to the model.
4//!
5//! Every door a document takes into the editor comes through here: the
6//! courtesy file open (F5) and the Import dialog / clipboard paste of a
7//! standalone document (`crate::widget::clipboard::block_from_document`)
8//! both lower through this bridge, and a caller naming the file's own
9//! spellings resolves them against the [`SourceIds`] table it builds along
10//! the way.
11//!
12//! A route whose endpoint pin is absent is rejected: the fold drops the
13//! route rather than keep an anchor dangling.
14
15use 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
39/// The whole document as one labeled commit — assets included, since a
40/// placement and the bytes it names belong to the same edit. An empty
41/// document lowers to no commits at all.
42pub 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/// A lowered document: the log that builds it, and the table that connects
60/// the file's own spellings to the ids this run minted for them.
61#[derive(Default)]
62pub struct Lowered {
63    pub commits: Vec<Commit>,
64    pub ids: SourceIds,
65}
66
67/// The file's `"b3"` / `"b3:p2"` names, resolved to the entities they became.
68///
69/// Every lowering mints fresh uuids, so a name authored against the *source*
70/// — an authored cue's `drag "b1:p1" "b2:p1"` — has nothing in the document
71/// left to key on. This table is the only thing that does.
72#[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
88/// The one non-emitter site that mints ids freely: this is a converter,
89/// not a gesture, so every entity in the file gets a fresh uuid and the
90/// file's own string ids survive only as the map that resolves its
91/// cross-references.
92struct Lowering {
93    ids: FreshIds,
94    blocks: HashMap<String, BlockId>,
95    /// Keyed by the anchor spelling a route would use for it —
96    /// `"b3:p2"` — so resolving an endpoint is a lookup, not a parse.
97    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    /// Dependency order: payloads, then blocks, then the top pointer, then
123    /// every pin — routes resolve endpoints across blocks, so they cannot
124    /// start until the last block's pins are in.
125    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        // An omitted body is auto-placed against every body the block holds
198        // at that moment, in stored order; the ones still unplaced are
199        // zero-sized and cannot collide, so one pass over this vector
200        // suffices.
201        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                    // `pin_accent` and `port_pin_accent` do not survive: D2
222                    // makes them derived from route roles, and nothing
223                    // derived is stored in the log.
224                    port_accent: role_from_accent(pin.port_accent),
225                    flip_lr: pin.fliplr,
226                }),
227            ));
228        }
229    }
230
231    /// One scope's wires and annotations. The document root and every block
232    /// hold the same four lists, and lower through this one pass.
233    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    /// The hash and box a placement resolves to; `None` where the file
342    /// names bytes it does not carry (the legacy load refuses the whole
343    /// document there — here the placement alone is dropped).
344    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/// A scope on the way in: the owner id its entities take, and the file's own
370/// spelling for it, which a bare route anchor inside it resolves against.
371#[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    /// The pin table key `anchor` names from this scope. The document's own
386    /// boundary ports are not part of the format, so at the root only a
387    /// qualified anchor can name anything — a bare one resolves to nothing,
388    /// exactly as an anchor naming an absent pin does.
389    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
406/// Which block claims each child. The tree is stored as child lists and
407/// read back as parent links, so this is the whole of the inversion.
408fn 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
418/// The two anchor spellings a route endpoint accepts, as one key: a bare
419/// `"p2"` names a pin on the route's own block, `"b3:p2"` one on a child.
420fn 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
435/// A pin's boundary slot, or `None` where the file gives none this build
436/// can read — the legacy load refuses the document over both
437/// (`MissingPinSide`, `BadLoc`); here the pin drops, and with it every
438/// route that anchored on it.
439fn 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
464/// The port body the file carried, or a zero-width sentinel for one it
465/// omitted (filled in by [`default_port_rect`] below). Height is always
466/// `PORT_HEIGHT` and is never stored.
467fn 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
477/// A block or area label under its per-kind fallback placement: a title
478/// falls back to bottom, a type label to top. Neither is the doc crate's own
479/// zero, so both are written out.
480fn 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
497/// `asset`'s decoded payload bytes — SVG text as-is, PNG unwrapped from
498/// base64. These are the bytes the id hashes, so the name really does
499/// describe its content.
500fn 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
512/// The payloads, keyed by the id their placements name. The variant is the
513/// format; the hash covers the bytes alone, so an asset keeps its identity
514/// across the fold no matter how it was named on the wire.
515fn 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    /// Every field the schema carries, in one file: an off-ladder block
542    /// height (nothing re-snaps here), both anchor spellings, a dangling
543    /// one, explicit and omitted port geometry, and artwork in both
544    /// placements. [`crate::schema::project`]'s tests round-trip this same
545    /// fixture, which is what keeps the two projections honest together.
546    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    /// The smallest document there is: a named top and nothing under it.
685    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    /// Lower and fold: what a `Repo` seeded with the log would hold.
692    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    /// The per-kind label defaults are the legacy ones, not the doc
770    /// crate's zero: a title falls back to bottom, a type label to top.
771    #[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    /// The port body: kept verbatim where the file gave one, and otherwise
829    /// auto-placed by the same [`default_port_rect`] formula the editor
830    /// uses for a freshly stamped pin — against every body the owning block
831    /// holds at that point, in the file's stored order.
832    #[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        // "in" is b0's first pin, omitted; "out" (b0's second) is explicit,
842        // so it is already a sibling body by the time "in" is placed.
843        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        // "a" is b1's only pin, omitted, with no sibling bodies to avoid.
859        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    /// A document with nothing in it produces no commit at all — the
949    /// builder's empty seal, not an empty commit.
950    #[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    /// A cue authored against the file's own names reaches the entities the
956    /// lowering minted for them — and only those: a name the file never
957    /// carried resolves to nothing.
958    #[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}