Skip to main content

blockworx/document_ng/
schema_convert.rs

1//! App-side bridge between the in-memory [`Document`] model and the
2//! standalone on-disk [`crate::schema`] representation. Everything that couples
3//! the schema crate to `document_ng` lives here: the infallible `from_*`
4//! projection, the fallible `into_*` reconstruction, the JSON/KDL wrapper
5//! functions, and the hand-written enum bridges (the schema enums carry serde;
6//! the `document_ng` enums carry the domain).
7//!
8//! `SchemaError` carries the source text (for `miette` diagnostics), so it is
9//! large by design — the internal `Result`s accept that rather than boxing.
10#![allow(clippy::result_large_err)]
11
12use std::str::FromStr;
13
14use base64::Engine as _;
15
16use crate::document_ng::{
17    Accents, AutoRoute, Block, BlockLabel, Comment, Decorations, Document, GridPos, GridRect,
18    GridSize, Image, ImageData, LabelSide, LineAnchor, LinearDistance, PinPort, PinSide, PinType,
19    TextBox, Waypoint,
20};
21use crate::schema::SchemaError;
22use crate::schema::enums;
23use crate::schema::loc::{format_loc, parse_loc};
24use crate::schema::model as schema;
25use crate::shape::port::PORT_HEIGHT;
26use crate::store::{
27    CommentId, IdMap, IdMapExt, IdSet, ImageId, PinId, RectId, RouteId, TextId, WaypointId,
28    WireLabelId,
29};
30use crate::widget::auto_route::AutoRouteExt;
31
32// ── Enum bridges ────────────────────────────────────────────────────────────
33// The schema enums carry serde; the domain enums carry no serde. These 1:1
34// bridges are the only place the two vocabularies meet.
35
36impl From<PinSide> for enums::PinSide {
37    fn from(v: PinSide) -> Self {
38        match v {
39            PinSide::East => enums::PinSide::East,
40            PinSide::West => enums::PinSide::West,
41        }
42    }
43}
44impl From<enums::PinSide> for PinSide {
45    fn from(v: enums::PinSide) -> Self {
46        match v {
47            enums::PinSide::East => PinSide::East,
48            enums::PinSide::West => PinSide::West,
49        }
50    }
51}
52
53impl From<PinType> for enums::PinType {
54    fn from(v: PinType) -> Self {
55        match v {
56            PinType::Input => enums::PinType::Input,
57            PinType::Output => enums::PinType::Output,
58            PinType::InOut => enums::PinType::InOut,
59        }
60    }
61}
62impl From<enums::PinType> for PinType {
63    fn from(v: enums::PinType) -> Self {
64        match v {
65            enums::PinType::Input => PinType::Input,
66            enums::PinType::Output => PinType::Output,
67            enums::PinType::InOut => PinType::InOut,
68        }
69    }
70}
71
72impl From<LabelSide> for enums::LabelSide {
73    fn from(v: LabelSide) -> Self {
74        match v {
75            LabelSide::Top => enums::LabelSide::Top,
76            LabelSide::Center => enums::LabelSide::Center,
77            LabelSide::Bottom => enums::LabelSide::Bottom,
78        }
79    }
80}
81impl From<enums::LabelSide> for LabelSide {
82    fn from(v: enums::LabelSide) -> Self {
83        match v {
84            enums::LabelSide::Top => LabelSide::Top,
85            enums::LabelSide::Center => LabelSide::Center,
86            enums::LabelSide::Bottom => LabelSide::Bottom,
87        }
88    }
89}
90
91// ── from_document: infallible projection onto the on-disk schema ─────────────
92
93impl From<&Document> for schema::Document {
94    fn from(doc: &Document) -> Self {
95        schema::Document {
96            top: doc.top_id.to_string(),
97            blocks: doc
98                .blocks
99                .iter()
100                .map(|(&id, b)| schema::Block::from_block(id, b))
101                .collect(),
102        }
103    }
104}
105
106/// Format a route anchor as `"p2"` (port) or `"b3:p2"` (child-block pin).
107fn format_anchor(anchor: LineAnchor) -> String {
108    match anchor {
109        LineAnchor::Port(p) => p.to_string(),
110        LineAnchor::Pin { block, pin } => format!("{block}:{pin}"),
111    }
112}
113
114/// Round a continuous quantity (label/route positions, sizes) to 1 decimal, so
115/// the on-disk form stays clean and greppable.
116fn round1(v: f32) -> f32 {
117    (v * 10.0).round() / 10.0
118}
119
120/// Emit a block label only when it differs from its per-kind `default`; `side` is
121/// dropped when it matches the default placement.
122fn from_label(label: &BlockLabel, default: &BlockLabel) -> Option<schema::Label> {
123    if label == default {
124        return None;
125    }
126    Some(schema::Label {
127        name: label.name.clone(),
128        side: (label.side != default.side).then(|| enums::LabelSide::from(label.side)),
129        offset: round1(label.offset),
130        hidden: label.hidden,
131    })
132}
133
134impl schema::Block {
135    pub fn from_block(id: RectId, b: &Block) -> Self {
136        // A port whose rect equals the auto-placement default omits its geometry
137        // (reconstructed on load); others carry it verbatim.
138        let auto: std::collections::HashMap<PinId, GridRect> =
139            b.auto_port_rects().into_iter().collect();
140        schema::Block {
141            id: id.to_string(),
142            x: b.inner.min.x,
143            y: b.inner.min.y,
144            w: b.inner.size.w,
145            h: b.inner.size.h,
146            role: b.role,
147            locked: b.locked,
148            title: from_label(&b.decorations.title, &BlockLabel::default()),
149            type_label: from_label(&b.decorations.type_label, &BlockLabel::upper_left()),
150            pins: b
151                .pins
152                .iter()
153                .map(|(&id, p)| schema::Pin::from_pin(id, p, auto.get(&id).copied()))
154                .collect(),
155            routes: b.routes.values().map(schema::Route::from_route).collect(),
156            texts: b.texts.values().map(schema::Text::from_text).collect(),
157            comments: b
158                .comments
159                .values()
160                .map(schema::Comment::from_comment)
161                .collect(),
162            images: b.images.values().map(schema::Image::from_symbol).collect(),
163            icon: b.icon.as_ref().map(schema::Image::from_symbol),
164            children: b.children.iter().map(|&cid| cid.to_string()).collect(),
165        }
166    }
167}
168
169impl schema::Pin {
170    pub fn from_pin(id: PinId, p: &PinPort, auto: Option<GridRect>) -> Self {
171        // Omit geometry when it matches the auto-placed default (height is always
172        // `PORT_HEIGHT`, never stored).
173        let is_auto = auto == Some(p.rect);
174        schema::Pin {
175            id: id.to_string(),
176            name: p.name.clone(),
177            type_label: p.type_label.clone(),
178            tag: p.tag.clone(),
179            tag_hidden: p.tag_hidden,
180            loc: Some(format_loc(p.side.into(), p.offset)),
181            x: (!is_auto).then_some(p.rect.min.x),
182            y: (!is_auto).then_some(p.rect.min.y),
183            w: (!is_auto).then_some(p.rect.size.w),
184            dir: (p.kind != PinType::InOut).then(|| p.kind.into()),
185            pin_accent: p.accents.pin_accent,
186            port_accent: p.accents.port_accent,
187            port_pin_accent: p.accents.port_pin_accent,
188            // A port faces `side.flip()` (natural) or `side` (flipped); the
189            // boolean records the flipped case.
190            fliplr: p.port_orientation == Some(p.side),
191        }
192    }
193}
194
195impl schema::Route {
196    pub fn from_route(r: &AutoRoute) -> Self {
197        schema::Route {
198            name: r.route_name().to_string(),
199            from: format_anchor(r.start()),
200            to: format_anchor(r.finish()),
201            role: r.role(),
202            waypoints: r
203                .iter_waypoints()
204                .map(|(_, w)| schema::Waypoint {
205                    x: w.pos.x,
206                    y: w.pos.y,
207                    locked: w.locked,
208                })
209                .collect(),
210            labels: r
211                .iter_labels()
212                .map(|(_, &d)| round1(f32::from(d)))
213                .collect(),
214        }
215    }
216}
217
218impl schema::Text {
219    pub fn from_text(t: &TextBox) -> Self {
220        schema::Text {
221            text: t.text.clone(),
222            x: t.anchor.x,
223            y: t.anchor.y,
224            role: t.role,
225        }
226    }
227}
228
229impl schema::Comment {
230    pub fn from_comment(c: &Comment) -> Self {
231        schema::Comment {
232            x: c.inner.min.x,
233            y: c.inner.min.y,
234            w: c.inner.size.w,
235            h: c.inner.size.h,
236            role: c.role,
237            title: from_label(&c.title, &BlockLabel::default()),
238        }
239    }
240}
241
242impl schema::Image {
243    pub fn from_symbol(s: &Image) -> Self {
244        let image = match &s.image {
245            ImageData::Svg(src) => schema::ImageData::Svg(src.clone()),
246            ImageData::Png(bytes) => {
247                schema::ImageData::Png(base64::engine::general_purpose::STANDARD.encode(bytes))
248            }
249        };
250        schema::Image {
251            x: round1(s.inner.min.x),
252            y: round1(s.inner.min.y),
253            w: round1(s.inner.width()),
254            h: round1(s.inner.height()),
255            image,
256        }
257    }
258}
259
260// ── into_document: fallible reconstruction from the on-disk schema ───────────
261
262fn parse_id<T: FromStr<Err = String>>(kind: &'static str, s: &str) -> Result<T, SchemaError> {
263    s.parse().map_err(|reason| SchemaError::Id {
264        kind,
265        value: s.to_string(),
266        reason,
267    })
268}
269
270fn parse_anchor(block: &str, s: &str) -> Result<LineAnchor, SchemaError> {
271    let anchor_err = |reason: String| SchemaError::Anchor {
272        block: block.to_string(),
273        value: s.to_string(),
274        reason,
275    };
276    if let Some((b, p)) = s.split_once(':') {
277        Ok(LineAnchor::Pin {
278            block: b.parse().map_err(anchor_err)?,
279            pin: p.parse().map_err(anchor_err)?,
280        })
281    } else {
282        Ok(LineAnchor::Port(s.parse().map_err(anchor_err)?))
283    }
284}
285
286impl schema::Label {
287    fn into_label(self, default: &BlockLabel) -> BlockLabel {
288        BlockLabel {
289            name: self.name,
290            hidden: self.hidden,
291            side: self.side.map_or(default.side, Into::into),
292            offset: self.offset,
293        }
294    }
295}
296
297fn label_or(dto: Option<schema::Label>, default: BlockLabel) -> BlockLabel {
298    match dto {
299        Some(l) => l.into_label(&default),
300        None => default,
301    }
302}
303
304impl schema::Document {
305    pub fn into_document(self) -> Result<Document, SchemaError> {
306        let top_id: RectId = self.top.parse().map_err(|reason| SchemaError::BadTop {
307            value: self.top.clone(),
308            reason,
309        })?;
310        let mut blocks: IdMap<RectId, Block> = IdMap::default();
311        for sb in self.blocks {
312            let (id, block) = sb.into_block()?;
313            blocks.insert(id, block);
314        }
315        if !blocks.contains_key(&top_id) {
316            return Err(SchemaError::BadTop {
317                value: self.top,
318                reason: "no block with that id".into(),
319            });
320        }
321        validate_children(&blocks)?;
322        Ok(Document {
323            top_id,
324            blocks: blocks.into(),
325        })
326    }
327}
328
329/// Strict on load (unlike the lenient runtime `child_blocks`): a child id that
330/// isn't in the document is a hand-edit error worth surfacing.
331fn validate_children(blocks: &IdMap<RectId, Block>) -> Result<(), SchemaError> {
332    for (bid, block) in blocks {
333        for cid in &block.children {
334            if !blocks.contains_key(cid) {
335                return Err(SchemaError::DanglingChild {
336                    block: bid.to_string(),
337                    child: cid.to_string(),
338                });
339            }
340        }
341    }
342    Ok(())
343}
344
345impl schema::Block {
346    pub fn into_block(self) -> Result<(RectId, Block), SchemaError> {
347        let id: RectId = parse_id("block", &self.id)?;
348        let block_ctx = self.id;
349
350        let decorations = Decorations {
351            title: label_or(self.title, BlockLabel::default()),
352            type_label: label_or(self.type_label, BlockLabel::upper_left()),
353        };
354
355        let mut pins: IdMap<PinId, PinPort> = IdMap::default();
356        for p in self.pins {
357            let (pid, pin) = p.into_pin(&block_ctx)?;
358            pins.insert(pid, pin);
359        }
360
361        // Routes/texts/comments/images carry no id on disk; mint fresh ones in
362        // stored order.
363        let mut routes: IdMap<RouteId, AutoRoute> = IdMap::default();
364        for r in self.routes {
365            routes.insert_value(r.into_route(&block_ctx)?);
366        }
367
368        let mut texts: IdMap<TextId, TextBox> = IdMap::default();
369        for t in self.texts {
370            texts.insert_value(t.into_text());
371        }
372
373        let mut comments: IdMap<CommentId, Comment> = IdMap::default();
374        for c in self.comments {
375            comments.insert_value(c.into_comment());
376        }
377
378        let mut images: IdMap<ImageId, Image> = IdMap::default();
379        for (index, s) in self.images.into_iter().enumerate() {
380            images.insert_value(s.into_symbol(&block_ctx, index)?);
381        }
382        let icon = match self.icon {
383            Some(s) => Some(s.into_symbol(&block_ctx, 0)?),
384            None => None,
385        };
386
387        let mut children: IdSet<RectId> = IdSet::default();
388        for cid in self.children {
389            children.insert(parse_id("block", &cid)?);
390        }
391
392        let block = Block {
393            inner: GridRect::new(GridPos::new(self.x, self.y), GridSize::new(self.w, self.h)),
394            decorations,
395            pins,
396            children,
397            routes,
398            texts,
399            comments,
400            images,
401            icon,
402            role: self.role,
403            locked: self.locked,
404        };
405        // Reconstruct any port geometry omitted on disk (matched the auto default).
406        let mut block = block;
407        block.fill_missing_port_rects();
408        Ok((id, block))
409    }
410}
411
412impl schema::Pin {
413    pub fn into_pin(self, block: &str) -> Result<(PinId, PinPort), SchemaError> {
414        let id: PinId = parse_id("pin", &self.id)?;
415        let loc = self.loc.ok_or_else(|| SchemaError::MissingPinSide {
416            block: block.to_string(),
417            pin: self.id.clone(),
418        })?;
419        let (side, offset) = parse_loc(&loc).map_err(|reason| SchemaError::BadLoc {
420            block: block.to_string(),
421            pin: self.id.clone(),
422            value: loc.clone(),
423            reason,
424        })?;
425        let side: PinSide = side.into();
426        let pin = PinPort {
427            name: self.name,
428            type_label: self.type_label,
429            tag: self.tag,
430            tag_hidden: self.tag_hidden,
431            side,
432            offset,
433            // Omitted geometry leaves a zero-width sentinel; `fill_missing_port_rects`
434            // (called by `into_block`) reconstructs it. Height is always PORT_HEIGHT.
435            rect: match (self.x, self.y, self.w) {
436                (Some(x), Some(y), Some(w)) => {
437                    GridRect::new(GridPos::new(x, y), GridSize::new(w, PORT_HEIGHT))
438                }
439                _ => GridRect::default(),
440            },
441            kind: self.dir.map(Into::into).unwrap_or_default(),
442            accents: Accents {
443                pin_accent: self.pin_accent,
444                port_accent: self.port_accent,
445                port_pin_accent: self.port_pin_accent,
446            },
447            // Flipped ⇒ facing == side; natural ⇒ None (facing derives side.flip()).
448            port_orientation: self.fliplr.then_some(side),
449        };
450        Ok((id, pin))
451    }
452}
453
454impl schema::Route {
455    pub fn into_route(self, block: &str) -> Result<AutoRoute, SchemaError> {
456        let from = parse_anchor(block, &self.from)?;
457        let to = parse_anchor(block, &self.to)?;
458
459        let mut waypoints: IdMap<WaypointId, Waypoint> = IdMap::default();
460        for w in self.waypoints {
461            waypoints.insert_value(Waypoint {
462                pos: GridPos::new(w.x, w.y),
463                locked: w.locked,
464            });
465        }
466        let mut labels: IdMap<WireLabelId, LinearDistance> = IdMap::default();
467        for at in self.labels {
468            labels.insert_value(LinearDistance::from(at));
469        }
470
471        // Empty geometry; the router rebuilds it after load.
472        let mut route = AutoRoute::build(from, to, &[], waypoints, labels);
473        route.set_route_name(self.name);
474        route.set_role(self.role);
475        Ok(route)
476    }
477}
478
479impl schema::Text {
480    pub fn into_text(self) -> TextBox {
481        TextBox {
482            text: self.text,
483            anchor: GridPos::new(self.x, self.y),
484            size: None,
485            role: self.role,
486        }
487    }
488}
489
490impl schema::Comment {
491    pub fn into_comment(self) -> Comment {
492        Comment {
493            inner: GridRect::new(GridPos::new(self.x, self.y), GridSize::new(self.w, self.h)),
494            title: label_or(self.title, BlockLabel::default()),
495            role: self.role,
496        }
497    }
498}
499
500impl schema::Image {
501    pub fn into_symbol(self, block: &str, index: usize) -> Result<Image, SchemaError> {
502        let image = match self.image {
503            schema::ImageData::Svg(svg) => ImageData::Svg(svg),
504            schema::ImageData::Png(png) => ImageData::Png(
505                base64::engine::general_purpose::STANDARD
506                    .decode(png.as_bytes())
507                    .map_err(|e| SchemaError::SymbolPng {
508                        block: block.to_string(),
509                        index,
510                        reason: e.to_string(),
511                    })?,
512            ),
513        };
514        Ok(Image {
515            image,
516            inner: egui::Rect::from_min_size(
517                egui::pos2(self.x, self.y),
518                egui::vec2(self.w, self.h),
519            ),
520        })
521    }
522}
523
524// ── JSON / KDL wrappers ──────────────────────────────────────────────────────
525
526/// Serialize the document as pretty JSON.
527pub fn to_json(doc: &Document) -> String {
528    schema::Document::from(doc).to_json()
529}
530
531/// Parse a JSON document. Routes come back with empty geometry; the caller must
532/// rebuild it before display (see `crate::widget::drawing::finalize_load`).
533pub fn from_json(src: &str, src_name: &str) -> miette::Result<Document> {
534    Ok(schema::Document::parse_json(src, src_name)?.into_document()?)
535}
536
537/// Serialize the document as KDL.
538pub fn to_kdl(doc: &Document) -> String {
539    schema::Document::from(doc).to_kdl()
540}
541
542/// Parse a KDL document. Like [`from_json`], routes need rebuilding before display.
543pub fn from_kdl(src: &str, src_name: &str) -> miette::Result<Document> {
544    Ok(schema::Document::parse_kdl(src, src_name)?.into_document()?)
545}
546
547#[cfg(test)]
548mod tests {
549    use super::{from_json, from_kdl, to_json, to_kdl};
550    use crate::document_ng::{
551        Accents, AutoRoute, Block, BlockLabel, Comment, Decorations, Document, GridPos, GridRect,
552        GridSize, Image, ImageData, LabelSide, LineAnchor, LinearDistance, PinPort, PinSide,
553        PinType, TextBox, Waypoint,
554    };
555    use crate::schema::SchemaError;
556    use crate::schema::model;
557    use crate::store::{
558        CommentId, IdMap, IdMapExt, IdSet, ImageId, PinId, RectId, RouteId, TextId, WaypointId,
559        WireLabelId,
560    };
561    use crate::widget::auto_route::AutoRouteExt;
562
563    fn pin(name: &str, side: PinSide, offset: u32, kind: PinType, rect: GridRect) -> PinPort {
564        PinPort {
565            name: name.to_string(),
566            type_label: String::new(),
567            tag: String::new(),
568            tag_hidden: false,
569            side,
570            offset,
571            rect,
572            kind,
573            accents: Accents::default(),
574            port_orientation: None,
575        }
576    }
577
578    fn rect(x: i32, y: i32, w: u32, h: u32) -> GridRect {
579        GridRect::new(GridPos::new(x, y), GridSize::new(w, h))
580    }
581
582    fn block(name: &str) -> Block {
583        Block {
584            decorations: Decorations {
585                title: BlockLabel {
586                    name: name.to_string(),
587                    ..BlockLabel::default()
588                },
589                ..Decorations::default()
590            },
591            ..Block::default()
592        }
593    }
594
595    fn route(
596        from: LineAnchor,
597        to: LineAnchor,
598        name: &str,
599        wps: &[(i32, i32)],
600        labels: &[f32],
601    ) -> AutoRoute {
602        let mut waypoints: IdMap<WaypointId, Waypoint> = IdMap::default();
603        for &(x, y) in wps {
604            waypoints.insert_value(Waypoint {
605                pos: GridPos::new(x, y),
606                locked: false,
607            });
608        }
609        let mut ld: IdMap<WireLabelId, LinearDistance> = IdMap::default();
610        for &l in labels {
611            ld.insert_value(LinearDistance::from(l));
612        }
613        let mut r = AutoRoute::build(from, to, &[], waypoints, ld);
614        r.set_route_name(name.to_string());
615        r
616    }
617
618    /// A representative document exercising every field the schema carries: pin
619    /// rects, type labels, accents, facing, routes with waypoints + labels, text,
620    /// comment, SVG image, role, locked, nested children.
621    fn demo() -> Document {
622        let top = RectId::nth_default(1);
623        let c1 = RectId::nth_default(2);
624        let c2 = RectId::nth_default(3);
625        let buffer = RectId::nth_default(4);
626
627        let mut t = block("block_1");
628        t.decorations.type_label.name = "Counter".to_string();
629        t.pins.insert(
630            PinId::nth_default(1),
631            pin("clk", PinSide::West, 1, PinType::Input, rect(-11, 28, 4, 2)),
632        );
633        t.pins.insert(
634            PinId::nth_default(2),
635            pin("rst", PinSide::West, 2, PinType::InOut, rect(-11, 32, 4, 2)),
636        );
637        t.children = IdSet::from_iter([c1, c2]);
638        t.role = Some(3);
639        t.locked = true;
640        t.texts.insert(
641            TextId::nth_default(1),
642            TextBox {
643                text: "line one\nline two".to_string(),
644                anchor: GridPos::new(5, 8),
645                size: None,
646                role: Some(2),
647            },
648        );
649        t.comments.insert(
650            CommentId::nth_default(1),
651            Comment {
652                inner: rect(2, 2, 10, 8),
653                title: BlockLabel {
654                    name: "Region".to_string(),
655                    side: LabelSide::Top,
656                    ..BlockLabel::default()
657                },
658                role: None,
659            },
660        );
661        t.images.insert(
662            ImageId::nth_default(1),
663            Image {
664                image: ImageData::Svg(
665                    "<svg viewBox=\"0 0 10 10\">\n  <path d=\"M0 0 L10 10\"/>\n</svg>".to_string(),
666                ),
667                inner: egui::Rect::from_min_size(egui::pos2(20.0, 15.0), egui::vec2(8.0, 8.0)),
668            },
669        );
670        t.icon = Some(Image {
671            image: ImageData::Svg(
672                "<svg viewBox=\"0 0 4 4\"><rect width=\"4\" height=\"4\"/></svg>".to_string(),
673            ),
674            inner: egui::Rect::from_min_size(egui::pos2(4.0, 4.0), egui::vec2(3.0, 3.0)),
675        });
676        let mut r1 = route(
677            LineAnchor::Pin {
678                block: c1,
679                pin: PinId::nth_default(1),
680            },
681            LineAnchor::Pin {
682                block: c2,
683                pin: PinId::nth_default(1),
684            },
685            "route_1",
686            &[(12, 21), (-16, 19)],
687            &[76.6],
688        );
689        r1.set_role(Some(5));
690        t.routes.insert(RouteId::nth_default(1), r1);
691        t.routes.insert(
692            RouteId::nth_default(2),
693            route(
694                LineAnchor::Pin {
695                    block: c2,
696                    pin: PinId::nth_default(1),
697                },
698                LineAnchor::Port(PinId::nth_default(1)),
699                "",
700                &[],
701                &[],
702            ),
703        );
704
705        let mut b1 = block("block_1");
706        b1.pins.insert(
707            PinId::nth_default(1),
708            pin(
709                "i.1.write_logic",
710                PinSide::West,
711                1,
712                PinType::InOut,
713                rect(0, 0, 8, 2),
714            ),
715        );
716        // A pin exercising every optional field.
717        let mut fancy = pin(
718            "typed",
719            PinSide::East,
720            3,
721            PinType::Output,
722            rect(20, 6, 5, 2),
723        );
724        fancy.type_label = "Counter".to_string();
725        fancy.tag = "U3".to_string();
726        fancy.tag_hidden = true;
727        fancy.accents = Accents {
728            pin_accent: Some(1),
729            port_accent: Some(2),
730            port_pin_accent: None,
731        };
732        // side == East, so Some(East) is the flipped state (fliplr=true) that the
733        // boolean encoding round-trips (Some(side.flip()) would collapse to None).
734        fancy.port_orientation = Some(PinSide::East);
735        b1.pins.insert(PinId::nth_default(2), fancy);
736
737        let mut b2 = block("block_2b");
738        b2.pins.insert(
739            PinId::nth_default(1),
740            pin(
741                "o.0.read_logic",
742                PinSide::East,
743                1,
744                PinType::InOut,
745                rect(30, 0, 8, 2),
746            ),
747        );
748        b2.children = IdSet::from_iter([buffer]);
749
750        let mut buf = block("Internal Buffer");
751        buf.pins.insert(
752            PinId::nth_default(1),
753            pin("Port", PinSide::West, 1, PinType::InOut, rect(22, 5, 4, 2)),
754        );
755
756        let mut blocks: IdMap<RectId, Block> = IdMap::default();
757        blocks.insert(top, t);
758        blocks.insert(c1, b1);
759        blocks.insert(c2, b2);
760        blocks.insert(buffer, buf);
761        Document {
762            top_id: top,
763            blocks: blocks.into(),
764        }
765    }
766
767    #[test]
768    fn json_round_trip_is_idempotent() {
769        let doc = demo();
770        let s1 = to_json(&doc);
771        let back = from_json(&s1, "demo.json").unwrap_or_else(|e| panic!("re-parse:\n{e:?}\n{s1}"));
772        let s2 = to_json(&back);
773        assert_eq!(s1, s2, "JSON must be idempotent\n{s1}");
774    }
775
776    #[test]
777    fn kdl_round_trip_is_idempotent() {
778        let doc = demo();
779        let s1 = to_kdl(&doc);
780        let back = from_kdl(&s1, "demo.kdl").unwrap_or_else(|e| panic!("re-parse:\n{e:?}\n{s1}"));
781        let s2 = to_kdl(&back);
782        assert_eq!(s1, s2, "KDL must be idempotent\n{s1}");
783    }
784
785    #[test]
786    fn json_and_kdl_agree() {
787        // Both formats are serializations of the same schema, so a doc routed through
788        // KDL and back must produce the same JSON as the original.
789        let doc = demo();
790        let via_kdl = from_kdl(&to_kdl(&doc), "demo.kdl").unwrap();
791        assert_eq!(to_json(&doc), to_json(&via_kdl));
792    }
793
794    #[test]
795    fn schema_projection_round_trips_by_value() {
796        // The schema itself (not the string) round-trips exactly through both formats.
797        let doc = demo();
798        let s = model::Document::from(&doc);
799        let via_json = from_json(&to_json(&doc), "d.json").unwrap();
800        let via_kdl = from_kdl(&to_kdl(&doc), "d.kdl").unwrap();
801        assert_eq!(s, model::Document::from(&via_json), "json");
802        assert_eq!(s, model::Document::from(&via_kdl), "kdl");
803    }
804
805    #[test]
806    fn image_fractional_size_round_trips() {
807        // Images are free-sized (not grid-quantized), so a fractional rect must
808        // survive both formats verbatim.
809        let mut doc = Document::default();
810        let top = doc.top_id;
811        let inner = egui::Rect::from_min_size(egui::pos2(3.5, 2.5), egui::vec2(37.5, 12.5));
812        doc.block_mut(top).unwrap().images.insert(
813            ImageId::nth_default(1),
814            Image {
815                image: ImageData::Svg("<svg/>".into()),
816                inner,
817            },
818        );
819        for back in [
820            from_json(&to_json(&doc), "d.json").unwrap(),
821            from_kdl(&to_kdl(&doc), "d.kdl").unwrap(),
822        ] {
823            let img = back.block(top).unwrap().images.values().next().unwrap();
824            assert_eq!(img.inner, inner, "fractional image rect preserved");
825        }
826    }
827
828    #[test]
829    fn ids_preserved_and_child_order_kept() {
830        let back = from_kdl(&to_kdl(&demo()), "d.kdl").unwrap();
831        assert_eq!(back.top_id, RectId::nth_default(1));
832        assert!(back.block(RectId::nth_default(4)).is_some());
833        let top = back.block(back.top_id).unwrap();
834        assert_eq!(
835            top.children.iter().copied().collect::<Vec<_>>(),
836            vec![RectId::nth_default(2), RectId::nth_default(3)]
837        );
838    }
839
840    #[test]
841    fn pin_rect_is_captured_not_recomputed() {
842        // The dragged/authored port rect survives a round-trip verbatim.
843        let back = from_json(&to_json(&demo()), "d.json").unwrap();
844        let clk = back
845            .block(RectId::nth_default(1))
846            .unwrap()
847            .pins
848            .get(&PinId::nth_default(1))
849            .unwrap();
850        assert_eq!(clk.rect, rect(-11, 28, 4, 2));
851    }
852
853    #[test]
854    fn fliplr_round_trips() {
855        let kdl = to_kdl(&demo());
856        assert!(kdl.contains("fliplr=true"), "{kdl}");
857        assert!(
858            !kdl.contains("facing") && !kdl.contains("port-orientation"),
859            "{kdl}"
860        );
861        let back = from_kdl(&kdl, "d.kdl").unwrap();
862        let fancy = back
863            .block(RectId::nth_default(2))
864            .unwrap()
865            .pins
866            .get(&PinId::nth_default(2))
867            .unwrap();
868        // fliplr=true reloads as port_orientation == side (East here).
869        assert_eq!(fancy.port_orientation, Some(PinSide::East));
870        // A natural-facing pin has no fliplr and reloads as None.
871        let clk = back
872            .block(RectId::nth_default(1))
873            .unwrap()
874            .pins
875            .get(&PinId::nth_default(1))
876            .unwrap();
877        assert_eq!(clk.port_orientation, None);
878    }
879
880    #[test]
881    fn route_anchors_are_positional_and_id_less() {
882        let kdl = to_kdl(&demo());
883        // Anchors are the two positional args; no `from=`/`to=` props and no id.
884        assert!(
885            kdl.contains(r#"route "b2:p1" "b3:p1" name="route_1""#),
886            "{kdl}"
887        );
888        assert!(!kdl.contains("from=") && !kdl.contains("to="), "{kdl}");
889    }
890
891    #[test]
892    fn label_position_round_trips_exactly() {
893        // The ×10 integer encoding makes the label position exact through a round-trip.
894        let doc = demo();
895        let orig = doc
896            .block(RectId::nth_default(1))
897            .unwrap()
898            .routes
899            .get(&RouteId::nth_default(1))
900            .unwrap()
901            .iter_labels()
902            .next()
903            .map(|(_, &d)| d)
904            .unwrap();
905        let back = from_kdl(&to_kdl(&doc), "d.kdl").unwrap();
906        let got = back
907            .block(RectId::nth_default(1))
908            .unwrap()
909            .routes
910            .values()
911            .find(|r| r.route_name() == "route_1")
912            .unwrap()
913            .iter_labels()
914            .next()
915            .map(|(_, &d)| d)
916            .unwrap();
917        assert_eq!(orig, got, "label LinearDistance preserved exactly");
918    }
919
920    #[test]
921    fn enums_are_kebab_case_in_json() {
922        let json = to_json(&demo());
923        assert!(json.contains(r#""dir": "output""#), "{json}");
924        assert!(
925            !json.contains("\"East\"") && !json.contains("\"InOut\""),
926            "{json}"
927        );
928    }
929
930    #[test]
931    fn loc_combines_side_and_offset() {
932        let kdl = to_kdl(&demo());
933        let json = to_json(&demo());
934        // "clk" is West, offset 1 → loc "w1"; no separate side/offset fields.
935        assert!(kdl.contains(r#"loc="w1""#), "{kdl}");
936        assert!(json.contains(r#""loc": "w1""#), "{json}");
937        // Pins no longer carry separate side/offset props (labels still use `side=`).
938        assert!(!kdl.contains(r#""clk" side="#), "{kdl}");
939        let back = from_kdl(&kdl, "d.kdl").unwrap();
940        let clk = back
941            .block(RectId::nth_default(1))
942            .unwrap()
943            .pins
944            .get(&PinId::nth_default(1))
945            .unwrap();
946        assert_eq!((clk.side, clk.offset), (PinSide::West, 1));
947    }
948
949    #[test]
950    fn pin_geometry_omitted_when_auto() {
951        let p = pin("clk", PinSide::West, 1, PinType::InOut, rect(5, 5, 4, 2));
952        // rect == auto-placed default → geometry omitted.
953        let auto = model::Pin::from_pin(PinId::nth_default(1), &p, Some(rect(5, 5, 4, 2)));
954        assert_eq!((auto.x, auto.y, auto.w), (None, None, None));
955        // rect != default (dragged) → geometry kept.
956        let kept = model::Pin::from_pin(PinId::nth_default(1), &p, Some(rect(9, 9, 4, 2)));
957        assert_eq!((kept.x, kept.y, kept.w), (Some(5), Some(5), Some(4)));
958    }
959
960    #[test]
961    fn hand_authored_pin_without_geometry_gets_auto_placed() {
962        // A pin declared with only `loc` (no x/y/w) parses and is auto-placed on load.
963        let src =
964            "top \"b1\"\nblock \"b1\" x=0 y=0 w=20 h=8 {\n    pin \"p1\" \"clk\" loc=\"w1\"\n}";
965        let back = from_kdl(src, "d.kdl").unwrap();
966        let p = back
967            .block(RectId::nth_default(1))
968            .unwrap()
969            .pins
970            .get(&PinId::nth_default(1))
971            .unwrap();
972        // Height is PORT_HEIGHT and width/position were filled in (non-zero width).
973        assert!(p.rect.size.w > 0, "port width was auto-filled");
974        assert_eq!(p.rect.size.h, crate::shape::port::PORT_HEIGHT);
975    }
976
977    #[test]
978    fn children_are_one_node() {
979        let kdl = to_kdl(&demo());
980        // The top block's children on a single node, in order.
981        assert!(kdl.contains(r#"children "b2" "b3""#), "{kdl}");
982        assert!(
983            !kdl.contains("child \"b2\""),
984            "no per-line child nodes:\n{kdl}"
985        );
986        let back = from_kdl(&kdl, "d.kdl").unwrap();
987        assert_eq!(
988            back.block(RectId::nth_default(1))
989                .unwrap()
990                .children
991                .iter()
992                .copied()
993                .collect::<Vec<_>>(),
994            vec![RectId::nth_default(2), RectId::nth_default(3)]
995        );
996    }
997
998    #[test]
999    fn bad_and_missing_loc_errors() {
1000        // In KDL both surface with a span (via the walker).
1001        let missing = "top \"b1\"\nblock \"b1\" x=0 y=0 w=40 h=30 {\n    pin \"p1\" \"clk\" x=0 y=0 w=4 h=2\n}";
1002        assert!(matches!(kdl_err(missing), SchemaError::Kdl { .. }));
1003        let bad = "top \"b1\"\nblock \"b1\" x=0 y=0 w=40 h=30 {\n    pin \"p1\" \"clk\" loc=\"z9\" x=0 y=0 w=4 h=2\n}";
1004        let err = kdl_err(bad);
1005        assert!(matches!(err, SchemaError::Kdl { .. }));
1006        let (off, len) = kdl_span(&err);
1007        assert_eq!(&bad[off..off + len], "\"z9\"", "span points at the bad loc");
1008    }
1009
1010    #[test]
1011    fn missing_loc_in_json_is_missing_pin_side() {
1012        // The JSON path (no walker) surfaces the friendly conversion error.
1013        let json = r#"{"top":"b1","blocks":[{"id":"b1","x":0,"y":0,"w":40,"h":30,
1014            "pins":[{"id":"p1","name":"clk","x":0,"y":0,"w":4,"h":2}]}]}"#;
1015        let err = from_json(json, "e.json")
1016            .unwrap_err()
1017            .downcast::<SchemaError>()
1018            .unwrap();
1019        assert!(matches!(err, SchemaError::MissingPinSide { .. }));
1020    }
1021
1022    #[test]
1023    fn defaults_omitted() {
1024        let kdl = to_kdl(&demo());
1025        assert!(!kdl.contains("in-out"), "default kind omitted:\n{kdl}");
1026    }
1027
1028    // ── Error cases ─────────────────────────────────────────────────────────────
1029
1030    fn json_err(src: &str) -> SchemaError {
1031        from_json(src, "e.json")
1032            .unwrap_err()
1033            .downcast::<SchemaError>()
1034            .expect("SchemaError")
1035    }
1036    fn kdl_err(src: &str) -> SchemaError {
1037        from_kdl(src, "e.kdl")
1038            .unwrap_err()
1039            .downcast::<SchemaError>()
1040            .expect("SchemaError")
1041    }
1042
1043    /// The span of a `Kdl` error, for asserting it points at the right token.
1044    fn kdl_span(err: &SchemaError) -> (usize, usize) {
1045        match err {
1046            SchemaError::Kdl { span, .. } => (span.offset(), span.len()),
1047            other => panic!("expected a Kdl error, got {other:?}"),
1048        }
1049    }
1050
1051    #[test]
1052    fn malformed_is_an_error() {
1053        assert!(matches!(kdl_err("top \"b1\" {"), SchemaError::Kdl { .. }));
1054        assert!(matches!(json_err("not json"), SchemaError::Json { .. }));
1055    }
1056
1057    #[test]
1058    fn bad_id_error_points_at_the_id() {
1059        let src = "top \"b1\"\nblock \"xyz\" x=0 y=0 w=4 h=4";
1060        let err = kdl_err(src);
1061        assert!(matches!(err, SchemaError::Kdl { .. }));
1062        // The span covers the `"xyz"` token.
1063        let (off, len) = kdl_span(&err);
1064        assert_eq!(&src[off..off + len], "\"xyz\"");
1065    }
1066
1067    #[test]
1068    fn bad_anchor_error() {
1069        // Anchors are positional; `"zzz"` is neither `p<N>` nor `b<N>:p<M>`.
1070        let src = "top \"b1\"\nblock \"b1\" x=0 y=0 w=40 h=30 {\n    route \"zzz\" \"p1\"\n}";
1071        assert!(matches!(kdl_err(src), SchemaError::Kdl { .. }));
1072    }
1073
1074    #[test]
1075    fn dangling_child_error() {
1076        let src = "top \"b1\"\nblock \"b1\" x=0 y=0 w=40 h=30 {\n    children \"b9\"\n}";
1077        assert!(matches!(kdl_err(src), SchemaError::DanglingChild { .. }));
1078    }
1079
1080    /// A `top` naming no block used to load "fine" and then take the editor down
1081    /// on the first frame: every draw resolves the current block through `top_id`.
1082    #[test]
1083    fn top_naming_a_missing_block_is_an_error() {
1084        let src = "top \"b9\"\nblock \"b1\" x=0 y=0 w=40 h=30";
1085        assert!(matches!(kdl_err(src), SchemaError::BadTop { .. }));
1086    }
1087
1088    #[test]
1089    fn image_without_data_error() {
1090        let src = "top \"b1\"\nblock \"b1\" x=0 y=0 w=40 h=30 {\n    image x=1 y=1 size=10.0\n}";
1091        assert!(matches!(kdl_err(src), SchemaError::Kdl { .. }));
1092    }
1093
1094    #[test]
1095    fn unknown_node_error() {
1096        let src = "top \"b1\"\nblock \"b1\" x=0 y=0 w=40 h=30 {\n    widget x=1\n}";
1097        assert!(matches!(kdl_err(src), SchemaError::Kdl { .. }));
1098    }
1099
1100    #[test]
1101    fn svg_with_quotes_round_trips() {
1102        let kdl = to_kdl(&demo());
1103        assert!(kdl.contains("svg r#\""), "{kdl}");
1104        let back = from_kdl(&kdl, "d.kdl").unwrap();
1105        let sym = back
1106            .block(RectId::nth_default(1))
1107            .unwrap()
1108            .images
1109            .get(&ImageId::nth_default(1))
1110            .unwrap();
1111        match &sym.image {
1112            ImageData::Svg(s) => assert!(s.contains("M0 0 L10 10") && s.contains('\n')),
1113            ImageData::Png(_) => panic!("expected svg"),
1114        }
1115    }
1116}