Skip to main content

blockworx/schema/
project.rs

1//! The write direction of the round trip: the folded document back as the
2//! schema structs the JSON codec serializes — one format authority for both
3//! directions.
4//!
5//! Order and naming are D11's: entities are numbered and written in **log
6//! order** — where each was first created — not by where they sit now. A
7//! block that moves therefore diffs as its own changed coordinates and
8//! nothing else, which is the whole reason `document.json` is worth reading
9//! (F5). The cost, taken deliberately, is that the projection is no longer
10//! a history-independent canonical form: the same diagram built in two
11//! different orders projects to two different files. `content_hash` is the
12//! equality oracle that replaced it.
13//!
14//! Deliberately lossy, in both directions' favor: asset ids are re-derived
15//! from content (a hand-written `"art.svg"` comes back as `"<hash>.svg"`),
16//! and port bodies are always written explicitly rather than re-omitted when
17//! they match the auto-placement — omission is a read-side reconstruction,
18//! and re-running it on export would move geometry the user never touched.
19
20use ahash::HashMap;
21use base64::Engine as _;
22use blockworx_doc::{
23    block_model::{Asset, Block, Icon, Label, Pin},
24    commit::Commit,
25    document::Document,
26    geometry::{GridPoint, GridRect},
27    id::{BlockId, Id, IdKind, PinId, RouteId},
28    opcode::{Crud, OpCodes},
29    repo::Repo,
30    values::{LabelSide, PinSide},
31};
32use uuid::Uuid;
33
34use crate::edit::lower::{
35    accent_from_role, artwork_rect, schema_label_side, schema_pin_dir, schema_pin_side,
36};
37use crate::path::Scope;
38use crate::schema::loc::format_loc;
39use crate::schema::model as schema;
40
41/// Every naming and ordering decision the projection makes: the generated
42/// spellings (`"b<N>"` per block in log order, `"p<N>"` per pin within its
43/// owner) and the log order they were taken from, which the entity lists
44/// below sort by.
45struct Names {
46    blocks: HashMap<BlockId, String>,
47    pins: HashMap<PinId, (BlockId, String)>,
48    created: CreationOrder,
49}
50
51impl Names {
52    fn rank<K: IdKind>(&self, id: &Id<K>) -> Rank {
53        self.created.rank(id)
54    }
55}
56
57/// Where an entity first appears in the log — the order D11 names and
58/// writes by. Not a bare index: the sort keys below say what they order by,
59/// and an entity the log never created (which the fold cannot produce, but a
60/// hand-built document can) sorts last instead of first.
61#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
62struct Rank(usize);
63
64impl Rank {
65    const UNCREATED: Rank = Rank(usize::MAX);
66}
67
68/// Every entity's [`Rank`], read off the log once per projection.
69///
70/// One table over every kind: ids are random uuids, so a single uuid space
71/// is unambiguous, and creation order is a property of the document rather
72/// than of any one namespace.
73struct CreationOrder(HashMap<Uuid, Rank>);
74
75impl CreationOrder {
76    fn of(log: &[Commit]) -> Self {
77        let mut ranks: HashMap<Uuid, Rank> = HashMap::default();
78        for id in log.iter().flat_map(Commit::ops).filter_map(minted) {
79            let next = Rank(ranks.len());
80            ranks.entry(id).or_insert(next);
81        }
82        Self(ranks)
83    }
84
85    fn rank<K: IdKind>(&self, id: &Id<K>) -> Rank {
86        self.0.get(&id.uuid()).copied().unwrap_or(Rank::UNCREATED)
87    }
88}
89
90/// The entity an op mints, if it mints one. A restore is not a first
91/// appearance — an entity is named for when it was born, and a name that
92/// moved when the entity came back would be exactly the churn D11 removes.
93fn minted(op: &OpCodes) -> Option<Uuid> {
94    fn born<I, U, K: IdKind>(id: &Id<K>, crud: &Crud<I, U>) -> Option<Uuid> {
95        matches!(crud, Crud::Create(_)).then(|| id.uuid())
96    }
97    match op {
98        OpCodes::Block(id, crud) => born(id, crud),
99        OpCodes::Pin(id, crud) => born(id, crud),
100        OpCodes::Route(id, crud) => born(id, crud),
101        OpCodes::RouteLabel(id, crud) => born(id, crud),
102        OpCodes::Text(id, crud) => born(id, crud),
103        OpCodes::Area(id, crud) => born(id, crud),
104        OpCodes::Image(id, crud) => born(id, crud),
105        OpCodes::Document(_) | OpCodes::Asset(..) => None,
106    }
107}
108
109/// The whole document, ordered and named by log order. The inverse of
110/// [`super::lower::lower`] up to minted identity.
111///
112/// Takes the repo rather than the folded document because the names are the
113/// log's to give: the fold keeps only the latest write order, which says
114/// when an entity last changed, never when it arrived.
115pub fn project(repo: &Repo) -> schema::Document {
116    let doc = repo.document();
117    project_with(doc, CreationOrder::of(repo.log()))
118}
119
120/// Serialize `repo`'s document as the JSON document format — the whole
121/// export path in one call.
122pub fn to_json(repo: &Repo) -> String {
123    project(repo).to_json()
124}
125
126fn project_with(doc: &Document, created: CreationOrder) -> schema::Document {
127    let order = block_order(doc, &created);
128    let pins = pins_by_owner(doc, &created);
129    let names = Names {
130        created,
131        blocks: order
132            .iter()
133            .enumerate()
134            .map(|(i, &(id, _))| (id, format!("b{i}")))
135            .collect(),
136        pins: pins
137            .iter()
138            .flat_map(|(&owner, pins)| {
139                pins.iter()
140                    .enumerate()
141                    .map(move |(i, &(id, _))| (id, (owner, format!("p{}", i + 1))))
142            })
143            .collect(),
144    };
145
146    let mut assets = AssetIds::default();
147    let blocks: Vec<schema::Block> = order
148        .iter()
149        .map(|&entry| {
150            project_block(
151                doc,
152                entry,
153                &names,
154                pins.get(&entry.0).map_or(&[][..], Vec::as_slice),
155                &mut assets,
156            )
157        })
158        .collect();
159
160    // After the blocks, so a payload's id is minted where a reader meets it
161    // first.
162    let root = project_scope(doc, Scope::Root, &names, &mut assets);
163
164    let name = doc.title_block().name.as_ref();
165    let top = names
166        .blocks
167        .get(doc.title_block().top.as_ref())
168        .or_else(|| order.first().and_then(|(id, _)| names.blocks.get(id)))
169        .cloned()
170        .unwrap_or_default();
171    schema::Document {
172        version: schema::CURRENT_VERSION,
173        name: (!name.is_empty()).then(|| name.clone()),
174        top,
175        blocks,
176        routes: root.routes,
177        texts: root.texts,
178        areas: root.areas,
179        images: root.images,
180        assets: assets.assets,
181    }
182}
183
184/// Position, then id, as the tiebreak below every log-order sort: two
185/// entities the log never created (a hand-built document) still need a
186/// total order, and one that is document state rather than a minted uuid
187/// keeps the round trip stable.
188fn position_key(rect: &GridRect, id: BlockId) -> (i32, i32, BlockId) {
189    (rect.top_left.y, rect.top_left.x, id)
190}
191
192/// Every live block in log order — the file's own order on the way in, so a
193/// re-import mints the same order it read.
194fn block_order<'a>(doc: &'a Document, created: &CreationOrder) -> Vec<(BlockId, &'a Block)> {
195    let mut live: Vec<(BlockId, &Block)> = doc
196        .blocks()
197        .filter(|(_, live)| live.is_alive())
198        .map(|(id, live)| (id, live.as_ref()))
199        .collect();
200    live.sort_by_key(|&(id, block)| (created.rank(&id), position_key(block.rect.as_ref(), id)));
201    live
202}
203
204fn pins_by_owner<'a>(
205    doc: &'a Document,
206    created: &CreationOrder,
207) -> HashMap<BlockId, Vec<(PinId, &'a Pin)>> {
208    let mut pins: HashMap<BlockId, Vec<(PinId, &Pin)>> = HashMap::default();
209    for (id, live) in doc.pins().filter(|(_, live)| live.is_alive()) {
210        let pin = live.as_ref();
211        pins.entry(*pin.owner.as_ref()).or_default().push((id, pin));
212    }
213    for owned in pins.values_mut() {
214        // Below the log order: two pins can share a slot (nothing in the
215        // format forbids it), so the tiebreak must be document state, not
216        // the minted id, or the order changes on every re-lower and the
217        // round trip fails on such files. The body rect and then the name
218        // settle every pair a reader could tell apart.
219        owned.sort_by_key(|(id, pin)| {
220            let slot = *pin.slot.as_ref();
221            let rect = *pin.rect.as_ref();
222            (
223                created.rank(id),
224                match slot.side {
225                    PinSide::West => 0_u8,
226                    PinSide::East => 1,
227                },
228                slot.offset,
229                rect.top_left.y,
230                rect.top_left.x,
231                pin.name.as_ref().clone(),
232                *id,
233            )
234        });
235    }
236    pins
237}
238
239/// Everything a scope holds beyond its blocks, projected. The document root
240/// and every block go through [`project_scope`] to fill one of these, so the
241/// two scopes cannot project their contents two different ways.
242struct ProjectedContents {
243    routes: Vec<schema::Route>,
244    texts: Vec<schema::Text>,
245    areas: Vec<schema::Area>,
246    images: Vec<schema::Image>,
247}
248
249fn project_scope(
250    doc: &Document,
251    scope: Scope,
252    names: &Names,
253    assets: &mut AssetIds,
254) -> ProjectedContents {
255    let owner = scope.wire_id();
256
257    let mut texts: Vec<(Rank, GridPoint, schema::Text)> = doc
258        .texts()
259        .filter(|(_, live)| live.is_alive())
260        .map(|(text_id, live)| (text_id, live.as_ref()))
261        .filter(|(_, text)| *text.owner.as_ref() == owner)
262        .map(|(text_id, text)| {
263            let pos = *text.pos.as_ref();
264            (
265                names.rank(&text_id),
266                pos,
267                schema::Text {
268                    text: text.text.as_ref().clone(),
269                    x: pos.x,
270                    y: pos.y,
271                    role: accent_from_role(*text.role.as_ref()),
272                },
273            )
274        })
275        .collect();
276    texts.sort_by(|(ra, a, ta), (rb, b, tb)| {
277        (ra, a.y, a.x, &ta.text).cmp(&(rb, b.y, b.x, &tb.text))
278    });
279
280    let mut areas: Vec<(Rank, schema::Area)> = doc
281        .areas()
282        .filter(|(_, live)| live.is_alive())
283        .map(|(area_id, live)| (area_id, live.as_ref()))
284        .filter(|(_, area)| *area.owner.as_ref() == owner)
285        .map(|(area_id, area)| {
286            let rect = *area.rect.as_ref();
287            (
288                names.rank(&area_id),
289                schema::Area {
290                    x: rect.top_left.x,
291                    y: rect.top_left.y,
292                    w: rect.size.w,
293                    h: rect.size.h,
294                    role: accent_from_role(*area.role.as_ref()),
295                    title: project_label(&area.title, LabelSide::Bottom),
296                },
297            )
298        })
299        .collect();
300    areas.sort_by_key(|(rank, c)| (*rank, c.y, c.x, c.w, c.h));
301
302    let mut images: Vec<(Rank, schema::Image)> = doc
303        .images()
304        .filter(|(_, live)| live.is_alive())
305        .map(|(image_id, live)| (image_id, live.as_ref()))
306        .filter(|(_, image)| *image.owner.as_ref() == owner)
307        .filter_map(|(image_id, image)| {
308            let asset = assets.id_for(doc, *image.asset.as_ref())?;
309            Some((
310                names.rank(&image_id),
311                placement(asset, *image.rect.as_ref()),
312            ))
313        })
314        .collect();
315    images.sort_by(|(ra, a), (rb, b)| {
316        ra.cmp(rb)
317            .then(a.y.total_cmp(&b.y))
318            .then(a.x.total_cmp(&b.x))
319            .then(a.asset.cmp(&b.asset))
320    });
321
322    ProjectedContents {
323        routes: project_routes(doc, owner, names),
324        texts: texts.into_iter().map(|(_, _, text)| text).collect(),
325        areas: areas.into_iter().map(|(_, area)| area).collect(),
326        images: images.into_iter().map(|(_, image)| image).collect(),
327    }
328}
329
330fn project_block(
331    doc: &Document,
332    (id, block): (BlockId, &Block),
333    names: &Names,
334    pins: &[(PinId, &Pin)],
335    assets: &mut AssetIds,
336) -> schema::Block {
337    let rect = *block.rect.as_ref();
338
339    let mut children: Vec<(GridRect, BlockId)> = doc
340        .blocks()
341        .filter(|(_, live)| live.is_alive())
342        .filter(|(_, live)| *live.as_ref().parent.as_ref() == id)
343        .map(|(kid, live)| (*live.as_ref().rect.as_ref(), kid))
344        .collect();
345    children.sort_by_key(|&(rect, kid)| (names.rank(&kid), position_key(&rect, kid)));
346
347    let contents = project_scope(doc, Scope::Block(id), names, assets);
348
349    let icon = block.icon.as_ref();
350    let icon = (*icon != Icon::default())
351        .then(|| Some(placement(assets.id_for(doc, icon.asset)?, icon.rect)))
352        .flatten();
353
354    schema::Block {
355        id: names.blocks[&id].clone(),
356        x: rect.top_left.x,
357        y: rect.top_left.y,
358        w: rect.size.w,
359        h: rect.size.h,
360        role: accent_from_role(*block.role.as_ref()),
361        locked: *block.locked.as_ref(),
362        title: project_label(&block.title, LabelSide::Bottom),
363        type_label: project_label(&block.type_label, LabelSide::Top),
364        pins: pins
365            .iter()
366            .map(|&(pin_id, pin)| project_pin(pin_id, pin, names))
367            .collect(),
368        routes: contents.routes,
369        texts: contents.texts,
370        areas: contents.areas,
371        images: contents.images,
372        icon,
373        children: children
374            .into_iter()
375            .map(|(_, kid)| names.blocks[&kid].clone())
376            .collect(),
377    }
378}
379
380fn project_pin(id: PinId, pin: &Pin, names: &Names) -> schema::Pin {
381    let slot = *pin.slot.as_ref();
382    let rect = *pin.rect.as_ref();
383    schema::Pin {
384        id: names.pins[&id].1.clone(),
385        name: pin.name.as_ref().clone(),
386        type_label: pin.type_name.as_ref().clone(),
387        tag: pin.tag.as_ref().clone(),
388        tag_hidden: *pin.tag_hidden.as_ref(),
389        loc: Some(format_loc(schema_pin_side(slot.side), slot.offset)),
390        x: Some(rect.top_left.x),
391        y: Some(rect.top_left.y),
392        w: Some(rect.size.w),
393        dir: schema_pin_dir(*pin.dir.as_ref()),
394        // Stub and port-pin accents are derived from route roles (swap D2)
395        // and are never document state, so they are never exported.
396        pin_accent: None,
397        port_accent: accent_from_role(*pin.port_accent.as_ref()),
398        port_pin_accent: None,
399        fliplr: *pin.flip_lr.as_ref(),
400    }
401}
402
403fn project_routes(doc: &Document, owner: BlockId, names: &Names) -> Vec<schema::Route> {
404    // Every anchor names a pin of some *block*, bare on the route's own block
405    // and qualified otherwise. A port of the document root has no block to
406    // name it, so a route onto one is unspellable and drops below.
407    let spell = |pin: PinId| -> Option<String> {
408        let (pin_owner, name) = names.pins.get(&pin)?;
409        let block = names.blocks.get(pin_owner)?;
410        Some(if *pin_owner == owner {
411            name.clone()
412        } else {
413            format!("{block}:{name}")
414        })
415    };
416    let mut labels: HashMap<RouteId, Vec<f32>> = HashMap::default();
417    for (_, live) in doc.route_labels().filter(|(_, live)| live.is_alive()) {
418        let label = live.as_ref();
419        labels
420            .entry(*label.owner.as_ref())
421            .or_default()
422            .push((*label.pos.as_ref()).into());
423    }
424
425    let mut routes: Vec<(Rank, schema::Route)> = doc
426        .routes()
427        .filter(|(_, live)| live.is_alive())
428        .filter(|(_, live)| *live.as_ref().owner.as_ref() == owner)
429        .filter_map(|(id, live)| {
430            let route = live.as_ref();
431            let (Some(from), Some(to)) = (spell(route.from), spell(route.to)) else {
432                // Unreachable through the fold (it refuses a route whose
433                // endpoint is absent), so worth a loud note if it happens.
434                tracing::warn!("not exporting a route with an unresolvable endpoint");
435                return None;
436            };
437            let mut positions = labels.remove(&id).unwrap_or_default();
438            positions.sort_by(|a, b| a.total_cmp(b));
439            Some((
440                names.rank(&id),
441                schema::Route {
442                    name: route.name.as_ref().clone(),
443                    from,
444                    to,
445                    role: accent_from_role(*route.role.as_ref()),
446                    waypoints: route
447                        .waypoints
448                        .as_ref()
449                        .iter()
450                        .map(|w| schema::Waypoint {
451                            x: w.pos.x,
452                            y: w.pos.y,
453                            locked: w.locked,
454                        })
455                        .collect(),
456                    labels: positions,
457                },
458            ))
459        })
460        .collect();
461    routes.sort_by(|(ra, a), (rb, b)| {
462        (ra, &a.from, &a.to, &a.name).cmp(&(rb, &b.from, &b.to, &b.name))
463    });
464    routes.into_iter().map(|(_, route)| route).collect()
465}
466
467/// A label namespace back as the schema's optional label. Collapses in two
468/// steps mirroring the loader's fallbacks: a side equal to the per-kind
469/// fallback is omitted, and a label all of whose fields are at their
470/// omitted-state values is not written at all.
471fn project_label(label: &Label, fallback: LabelSide) -> Option<schema::Label> {
472    let name = label.name.as_ref();
473    let side = *label.side.as_ref();
474    let offset: f32 = (*label.offset.as_ref()).into();
475    let hidden = *label.hidden.as_ref();
476    if name.is_empty() && side == fallback && offset == 0.0 && !hidden {
477        return None;
478    }
479    Some(schema::Label {
480        name: name.clone(),
481        side: (side != fallback).then(|| schema_label_side(side)),
482        offset,
483        hidden,
484    })
485}
486
487fn placement(asset: String, rect: blockworx_doc::geometry::ScreenRect) -> schema::Image {
488    let rect = artwork_rect(rect);
489    schema::Image {
490        asset,
491        x: rect.min.x,
492        y: rect.min.y,
493        w: rect.width(),
494        h: rect.height(),
495    }
496}
497
498/// Payloads keyed by their content hash, named content-derived
499/// (`"<hash16>.<ext>"`) exactly as the legacy writer named them — equal
500/// images get equal ids in every document, and an unrelated edit never
501/// renumbers anything.
502#[derive(Default)]
503struct AssetIds {
504    ids: Vec<(blockworx_doc::hash::AssetHash, String)>,
505    assets: Vec<schema::Asset>,
506}
507
508impl AssetIds {
509    /// The id naming `hash`, minting (and recording the payload) on first
510    /// sight. `None` where the document holds a placement but not the bytes
511    /// it names — the fold ought to bar that, so it is worth a loud note.
512    fn id_for(&mut self, doc: &Document, hash: blockworx_doc::hash::AssetHash) -> Option<String> {
513        if let Some((_, id)) = self.ids.iter().find(|(seen, _)| *seen == hash) {
514            return Some(id.clone());
515        }
516        let Some(asset) = doc.asset(&hash) else {
517            tracing::warn!("not exporting a placement of {hash}: the document holds no payload");
518            return None;
519        };
520        let (bytes, ext, image) = match asset {
521            Asset::Svg(bytes) => (
522                &**bytes,
523                "svg",
524                schema::ImageData::Svg(String::from_utf8_lossy(bytes).into_owned()),
525            ),
526            Asset::Png(bytes) => (
527                &**bytes,
528                "png",
529                schema::ImageData::Png(base64::engine::general_purpose::STANDARD.encode(bytes)),
530            ),
531        };
532        let id = format!("{}.{ext}", &blake3::hash(bytes).to_hex()[..16]);
533        self.assets.push(schema::Asset {
534            id: id.clone(),
535            image,
536        });
537        self.ids.push((hash, id.clone()));
538        Some(id)
539    }
540}
541
542#[cfg(test)]
543pub(crate) mod tests {
544    use super::*;
545    use crate::schema::lower::lower;
546
547    /// `lower`'s own everything-in-one-file fixture, round-tripped here.
548    const RICH: &str = crate::schema::lower::tests::RICH;
549
550    /// Lower and fold a parsed document into a repo — the projection's own
551    /// oracle for what a session seeded from it would hold, and the log the
552    /// sticky names come from. Shared with the round-trip gates
553    /// ([`crate::schema::roundtrip`]).
554    pub(crate) fn folded_repo(doc: &schema::Document, label: &str) -> Repo {
555        let lowered = lower(doc, label);
556        Repo::folding(&lowered.commits).expect("the lowered commits fold")
557    }
558
559    fn folded(src: &str) -> Repo {
560        let parsed = schema::Document::parse_json(src, "project").expect("the fixture parses");
561        folded_repo(&parsed, "project")
562    }
563
564    fn block_named<'a>(doc: &'a schema::Document, title: &str) -> &'a schema::Block {
565        doc.blocks
566            .iter()
567            .find(|b| b.title.as_ref().is_some_and(|t| t.name == title))
568            .unwrap_or_else(|| panic!("no projected block titled {title:?}"))
569    }
570
571    /// The projection carries every stored field back out — spot-checked
572    /// against the fixture's own values so a field `project` dropped cannot
573    /// hide behind the round trip's symmetry.
574    #[test]
575    fn the_rich_fixture_projects_with_its_fields_intact() {
576        let projected = project(&folded(RICH));
577
578        assert_eq!(projected.version, schema::CURRENT_VERSION);
579        assert_eq!(projected.name.as_deref(), Some("bridge"));
580
581        let sheet = block_named(&projected, "sheet");
582        assert_eq!(projected.top, sheet.id, "the top pointer survives");
583        assert_eq!((sheet.x, sheet.y, sheet.w, sheet.h), (0, 0, 30, 20));
584        let title = sheet.title.as_ref().expect("the sheet keeps its title");
585        assert_eq!(
586            (title.side, title.offset, title.hidden),
587            (Some(crate::schema::enums::LabelSide::Center), 1.5, true),
588        );
589
590        let core = block_named(&projected, "core");
591        assert_eq!(sheet.children, vec![core.id.clone()], "nesting survives");
592        assert_eq!(core.role, Some(3));
593        assert!(core.locked);
594        assert_eq!(
595            core.type_label.as_ref().map(|t| (t.name.as_str(), t.side)),
596            Some(("Add", None)),
597            "a type label at its per-kind default side omits the side",
598        );
599
600        let pin = |block: &schema::Block, name: &str| -> schema::Pin {
601            block
602                .pins
603                .iter()
604                .find(|p| p.name == name)
605                .unwrap_or_else(|| panic!("no projected pin named {name:?}"))
606                .clone()
607        };
608        let input = pin(sheet, "in");
609        assert_eq!(input.loc.as_deref(), Some("w0"));
610        assert_eq!(input.dir, Some(crate::schema::enums::PinType::Input));
611        assert_eq!((input.tag.as_str(), input.tag_hidden), ("A", true));
612        assert_eq!(input.port_accent, Some(2));
613        assert!(input.fliplr);
614        assert!(
615            input.x.is_some() && input.w.is_some(),
616            "an omitted port body exports explicitly once reconstructed",
617        );
618        let output = pin(sheet, "out");
619        assert_eq!((output.x, output.y, output.w), (Some(4), Some(6), Some(5)));
620        assert_eq!(output.type_label, "bit");
621        assert_eq!(
622            pin(core, "a").dir,
623            None,
624            "an in-out pin omits `dir`, the spelling that reads back as in-out",
625        );
626
627        let route = {
628            assert_eq!(sheet.routes.len(), 1, "the dangling route stayed dropped");
629            &sheet.routes[0]
630        };
631        assert_eq!(route.name, "net");
632        assert_eq!(route.role, Some(1));
633        assert_eq!(
634            route.from,
635            pin(sheet, "in").id,
636            "an own-block anchor is bare"
637        );
638        assert_eq!(
639            route.to,
640            format!("{}:{}", core.id, pin(core, "a").id),
641            "a child anchor is qualified",
642        );
643        assert_eq!(
644            route.waypoints,
645            vec![
646                schema::Waypoint {
647                    x: 12,
648                    y: 35,
649                    locked: false
650                },
651                schema::Waypoint {
652                    x: 2,
653                    y: 41,
654                    locked: true
655                },
656            ],
657        );
658        assert_eq!(route.labels, vec![7.5]);
659
660        assert_eq!(sheet.texts.len(), 1);
661        assert_eq!(
662            (sheet.texts[0].x, sheet.texts[0].y, sheet.texts[0].role),
663            (10, 5, Some(1)),
664        );
665        assert_eq!(sheet.areas.len(), 1);
666        assert_eq!(
667            sheet.areas[0].title.as_ref().map(|t| t.name.as_str()),
668            Some("group"),
669        );
670
671        assert_eq!(projected.assets.len(), 1, "one payload for two placements");
672        let asset = &projected.assets[0];
673        assert_eq!(
674            asset.id,
675            format!("{}.svg", &blake3::hash(b"<svg/>").to_hex()[..16]),
676            "the id is content-derived, replacing the file's own spelling",
677        );
678        assert_eq!(sheet.images.len(), 1);
679        assert_eq!(sheet.images[0].asset, asset.id);
680        assert_eq!(
681            sheet.icon.as_ref().map(|icon| icon.asset.as_str()),
682            Some(asset.id.as_str()),
683        );
684        assert_ne!(
685            (sheet.images[0].x, sheet.images[0].y),
686            (
687                sheet.icon.as_ref().unwrap().x,
688                sheet.icon.as_ref().unwrap().y
689            ),
690            "the two placements keep their own boxes",
691        );
692    }
693
694    /// The round trip: projected JSON, read back through the real pipeline,
695    /// projects identically — lowering mints ids in file order, so the file
696    /// order it read is the creation order it writes back.
697    #[test]
698    fn parse_lower_fold_project_is_identity_on_the_projection() {
699        let projected = project(&folded(RICH));
700        let reparsed = schema::Document::parse_json(&projected.to_json(), "projection")
701            .expect("the projection parses as JSON");
702        assert_eq!(project(&folded_repo(&reparsed, "project")), projected);
703    }
704
705    /// A source document whose file order disagrees with every positional
706    /// order — the fixture the stickiness test needs.
707    const OUT_OF_ORDER: &str = r#"
708{
709  "version": 2,
710  "top": "b0",
711  "blocks": [
712    {
713      "id": "b0",
714      "x": 0,
715      "y": 0,
716      "w": 40,
717      "h": 40,
718      "title": {
719        "name": "sheet"
720      },
721      "children": [
722        "b1",
723        "b2"
724      ]
725    },
726    {
727      "id": "b1",
728      "x": 0,
729      "y": 20,
730      "w": 8,
731      "h": 5,
732      "title": {
733        "name": "lower"
734      }
735    },
736    {
737      "id": "b2",
738      "x": 0,
739      "y": 5,
740      "w": 8,
741      "h": 5,
742      "title": {
743        "name": "upper"
744      }
745    }
746  ]
747}
748"#;
749
750    /// D11: names come from the log, so moving a block changes that block's
751    /// coordinates and nothing else. The precondition is asserted, not
752    /// assumed — under the positional order this replaced, `b1` and `b2`
753    /// would already have swapped before the move, and would swap again
754    /// after it.
755    #[test]
756    fn a_move_changes_one_blocks_coordinates_and_no_ones_name() {
757        let mut repo = folded(OUT_OF_ORDER);
758        let named = |doc: &schema::Document, title: &str| -> schema::Block {
759            doc.blocks
760                .iter()
761                .find(|b| b.title.as_ref().is_some_and(|t| t.name == title))
762                .unwrap_or_else(|| panic!("no projected block titled {title:?}"))
763                .clone()
764        };
765
766        let before = project(&repo);
767        assert_eq!(
768            (named(&before, "lower").id, named(&before, "upper").id),
769            ("b1".to_owned(), "b2".to_owned()),
770            "names follow the file's order",
771        );
772        assert!(
773            named(&before, "upper").y < named(&before, "lower").y,
774            "precondition: the file order already disagrees with the positional \
775             one, so `upper` would have been b1 under the numbering this replaced",
776        );
777
778        let lower_id = repo
779            .document()
780            .blocks()
781            .find(|(_, live)| live.as_ref().title.name.as_ref() == "lower")
782            .map(|(id, _)| id)
783            .expect("the lower block is in the document");
784        repo.submit(Commit::new(
785            "Moved a block".to_owned(),
786            vec![OpCodes::Block(
787                lower_id,
788                Crud::Update(blockworx_doc::block_model::BlockUpdate::Rect(GridRect {
789                    top_left: GridPoint { x: 0, y: 1 },
790                    size: blockworx_doc::geometry::GridSize { w: 8, h: 5 },
791                })),
792            )],
793        ))
794        .expect("the move folds");
795
796        let after = project(&repo);
797        assert!(
798            named(&after, "lower").y < named(&after, "upper").y,
799            "precondition: the move crossed the two blocks, so the positional \
800             numbering would have swapped them a second time",
801        );
802        assert_eq!(
803            after.blocks.iter().map(|b| &b.id).collect::<Vec<_>>(),
804            before.blocks.iter().map(|b| &b.id).collect::<Vec<_>>(),
805            "the numbering must not follow the move",
806        );
807        let differing: Vec<&str> = before
808            .blocks
809            .iter()
810            .zip(&after.blocks)
811            .filter(|(was, is)| was != is)
812            .map(|(was, _)| was.id.as_str())
813            .collect();
814        assert_eq!(differing, vec!["b1"], "only the moved block differs");
815        assert_eq!(named(&after, "lower").y, 1);
816    }
817
818    /// The degenerate export: nothing to say, but still a well-formed
819    /// document shape (an empty block list and an empty top pointer).
820    #[test]
821    fn an_empty_document_projects_empty() {
822        let projected = project(&Repo::default());
823        assert_eq!(projected.blocks, vec![]);
824        assert_eq!(projected.top, "");
825        assert_eq!(projected.name, None);
826    }
827}