fix: core review round 1 — validate columns children, split layout.rs
Spec §2.4: a columns block's props.children names blocks in the same slide. Model::check now validates it on add_block/set_block (unknown id, another slide's id, and self-reference all rejected), remove_block drops a removed id from any columns block's children instead of rejecting the removal, and duplicate_slide's model tracks the remap so a later op in the same batch validates against the copy's new ids. Split the §2.3 layout-conversion geometry (default_region, region_rect, region_of, to_canvas, to_flow) out of the 1089-line ops.rs into core/src/layout.rs, with unit tests for the box table, equal region splitting, z-by-index, and canvas-to-flow y-then-x ordering; ops.rs still calls it, nothing else restructured. Minor: reworded the vendored fastrand patch comment and build-wasm.sh's --lib comment to this repo (they still named Docs' paths), and noted why a table's cols-only resize reflows cells row-major.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01563XGCgxcSZToEXwuTBsKE
core/src/layout.rs+327 −0core/src/lib.rs+1 −0core/src/ops.rs+140 −113core/tests/ops.rs+133 −0core/vendor/fastrand-2.5.0/Cargo.toml+2 −2internal/slides/core/testdata/slides_core.wasmbinaryscripts/build-wasm.sh+5 −5
Intent
<task-notification> <task-id>abbcfd42cac482e25</task-id> <tool-use-id>toolu_01D6Wdt3QzwBCFqXYv3PQmwA</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/abbcfd42cac482e25.output</output-file> <status>completed</status> <summary>Agent "Re-review Task 1 fix round 1" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>### Findings - **Important 1** (no-membership-oracle under rate limiting): **ADDRESSED** — `internal/slides/auth.go:475-524`. `gateBegin` now wraps the writer handed to `next` in `sameAnswer{ResponseWriter: w, logger: logger}` (auth.go:488), whose `WriteHeader` rewrites any `Location` starting `signinPath+"?err="` back to `sentLocation` (auth.go:515-523) before the status line is sent. Verified against `amadan.net/rastrillo/rastrillo@v0.26.0/auth/handlers.go`: `Begin` always redirects with `http.StatusSeeOther` for every outcome (`?err=rate`, `?err=address`, `?err=1`, `?sent=1`) via the same `a.redirect` helper, and `signin@v0.1.1`'s default `AddressBudget` is 5 — matching the test's assumptions. Ran the new test directly: `TestSigninAnswerIsIdenticalOverTheRateLimitBudget` — PASS (7 POSTs of an allowed address, byte-identical to a denied one; mailer capped at 5). Doc comment rewritten with an explicit "THE GUARANTEE" section correcting the prior false claim. Keymail branch is unreachable because `force=1` is set unconditionally (pre-existing, untouched). - **Important 2** (raw Config stored on app.cfg): **ADDRESSED** — `internal/slides/app.go:344-377`, `internal/slides/config.go:832-867`. New `resolve(cfg)` applies every fail-closed default and returns the fully resolved `Config`; `newApp` calls it and constructs `&app{db: d, logger: logger, cfg: cfg, allowed: list}` (app.go:376) from the *resolved* `cfg`, and `New`'s `csrf.Protect` now uses `a.cfg.Origin` (app.go:303) rather than the caller's raw `cfg.Origin`. Ran `TestDevelopmentStoresTheResolvedConfig` and `TestSuppliedConfigSurvivesResolution` — both PASS. - **Important 3** (upsert+select on every request): **ADDRESSED** — `internal/slides/auth.go:599-630`. `ensureUser` now does `Take` first and only falls through to the `ON CONFLICT DO NOTHING` insert + read-back on `gorm.ErrRecordNotFound`. Ran `TestEnsureUserWritesOnceOnly` (counts actual `Create()` invocations via a GORM callback, asserts exactly 1 across 3 calls) — PASS. - **Ruling** (CARLOS_SMTP_* fallback per-variable): **ADDRESSED** — `cmd/slides/main.go:87-171`. `smtpSetting`/`smtpSecret` read `$SLIDES_SMTP_<NAME>` first, falling back to `$CARLOS_SMTP_<NAME>`, per variable (not per set), consistent with the ruling. Documented in `newMailer`'s doc comment and in `Config.Mailer`'s doc comment (config.go:795-798). - **Minor 4** (Sec-Fetch-Site making Origin dead): **ADDRESSED** — `internal/slidestest/harness_test.go` gained `postLegacy` (no `Sec-Fetch-Site`); `TestSigninAcceptsALegacyOriginOnlyPost` exercises the app's resolved origin from outside the package. Ran it — PASS. - **Minor 5** (explicit SigninPath): **ADDRESSED** — `internal/slides/auth.go:560` — `newAuthPlugin` now sets `SigninPath: signinPath` explicitly in `rauth.Config`. - **Minor 6** (index failure path via ErrorPage): **ADDRESSED** — `internal/slides/handlers.go:1078-1086` — now calls `ErrorPage(w, r, http.StatusInternalServerError, ref)` with `rastrillo.NewRef()`, matching `ErrorPage`'s real signature in `render.go:91`. - **Minor 7** (extractEnvFlag valueless trailing -env): **ADDRESSED** — `cmd/slides/main.go:59-84` — returns an error when `-env`/`--env` is the last argument; `main` logs and exits 1 (main.go:193-197). ### New Breakage in Fix Diff None found. One latent (not newly introduced) fragility worth naming: `sameAnswer.WriteHeader` only rewrites `Location` headers matching `signinPath+"?err="`; if `Begin`'s own `sameOrigin` check ever failed, it answers via `http.Error` (403, no `Location`) which `sameAnswer` would pass through unrewritten — a real divergence from the denied path's 303. Verified this is unreachable in practice: `csrf.Protect(origin)` (app-wide middleware, ahead of `gateBegin`) and `rauth`'s own `sameOrigin` both delegate to the identical `csrf.SameOrigin(r, origin)` with the same resolved `a.cfg.Origin`, so any request reaching `gateBegin` has already passed the identical check. Not something this diff introduced or needed to fix — just a coupling worth knowing about if that origin value is ever allowed to diverge between the two call sites. ### Out-of-Scope Observations - `make ci`'s `migration-check` target fails with `rastrillo: expected exactly one app package under ./internal, found [core slides]` — the report attributes this to Task 2's cherry-picked `internal/core` and claims it reproduces identically on `b745b2f` before this round's changes. `internal/core` is not present in this worktree checkout to independently re-verify that claim, but `go vet ./...` and `go test ./...` (the other two `make ci` targets) are unaffected and pass. Flagged by the implementer as needing controller reconciliation, not something in scope for this fix round. - Report's own "Concerns" section (copy review not done, `auth.go` duplication, `signinPath`/rauth-default coupling, locale middleware wired twice, `Config.Blobs` untested) are pre-existing/process notes from the original task report, not new items from this fix round. ### Verdict All findings addressed.</result> <usage><subagent_tokens>101718</subagent_tokens><tool_uses>17</tool_uses><duration_ms>152324</duration_ms></usage> </task-notification>
<task-notification> <task-id>abd466b0d6ffa844b</task-id> <tool-use-id>toolu_01R4XHoqr3J6v5wH5jLUaVmt</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/abd466b0d6ffa844b.output</output-file> <status>completed</status> <summary>Agent "Review Task 2 (spec + quality)" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>That's fine, I have what I need. Let me finalize the review. ### Spec Compliance - ✅ ABI surface (all `extern "C"` exports, handle-slab pattern, `ERR_*` codes, `out`/`err` read pairs) matches the brief exactly — `core/src/abi.rs:1-427`, `core/src/lib.rs:97-99` (`slides_core_abi_version`). - ✅ View JSON shape matches the brief's example verbatim, including omission of false/absent mark keys, `props.cells` folding, and defaulted keys — `core/src/view.rs:1-168`. - ✅ Full operation list implemented (`set_meta`, `add_slide`, `remove_slide`, `move_slide`, `duplicate_slide`, `set_slide` incl. layout conversion, `set_notes`, `add_block`, `remove_block`, `move_block`, `set_block`, `set_text`, `set_phone`, `register_asset`, `remove_asset`) — `core/src/ops.rs:127-262` (enum), `:497-737` (apply). - ✅ Markdown subset (bold/italic/code/highlight/link, list-item paragraphs, `\*` escape, unclosed-delimiter-as-literal) matches the brief and is unit-tested, including the unclosed `**`/`*` edge case — `core/src/markdown.rs:1-344`. - ✅ Layout conversion boxes and canvas↔flow rules match §2.3 exactly (region rects, aside narrowing body, z=index, canvas→flow ordering by y then x with heading/media/body classification) — `core/src/ops.rs:1830-1900` (`region_rect`, `to_canvas`, `to_flow`). - ✅ Table cells stored as a separate `cells` array on the block map and folded into `props.cells` in the view; `set_block` resize truncates/appends per the literal wording of the constraint — `core/src/ops.rs:2589-2611`, `core/src/view.rs:110-121`. - ✅ Go host API (`New`, `NewDoc`, `Apply`, `State`, `JSON`, `Ops`, `Close`) and Docs' trap-containment machinery (`rebuild`, `RecoveredError`/`Recovered`, `RejectedError`/`Rejected`) are present and behave as specified — `internal/core/core.go` (per diff, since the file has since been relocated to `internal/slides/core/core.go` by a concurrent agent; content is unchanged, confirmed by line count and diff). - ✅ Global constraints: zero host imports (test + `.cargo/config.toml`/vendored `fastrand` fix, verified as a faithful, correctly-renamed copy of Docs' own fix), no core-side entropy for ids (`clippy.toml` `disallowed-methods`, verified against real yrs 0.23.5 source line numbers), one transaction with origin `b"ops"` (`core/src/ops.rs:2732`: `doc.transact_mut_with("ops")`). - ❌ Issue found: `columns` block's `props.children` (a list of block ids per §2.4) is never validated against the slide's actual blocks by `add_block`/`set_block`/`duplicate_slide`'s `rewrite_ids` (only `rewrite_ids` remaps ids it recognizes, silently leaving unknown ones as-is). A `set_block` or `add_block` can set `children` to ids that don't exist, or that belong to another slide, and the core will accept it — see `core/src/ops.rs:2604-2626` (`SetBlock` props merge) and the check pass at `core/src/ops.rs:1972-1990` (no `children` check for `columns`). Not a crash risk (view.rs never dereferences `children`), but it's an unenforced schema invariant the review brief specifically asked to check. - ⚠️ Cannot verify from diff: the `internal/core/testdata/slides_core.wasm` byte-identity and `cargo test`/`go test` results (per instructions, already verified by the controller and not re-run here). ### Strengths - The Rust core is unusually well-documented and technically precise — e.g. `core/src/lib.rs:1-56` and `core/vendor/README.md` cite exact upstream `yrs`/`getrandom`/`fastrand` source lines and behavior, which I independently verified against `~/.cargo/registry/src/*/yrs-0.23.5/` (`Options::with_guid_and_client_id`, `ClientID = u64`, `Uuid = Arc<str>` all check out exactly as described). - `core/tests/ops.rs` goes well beyond the five tests the brief required (`remaining_ops_roundtrip` alone exercises nearly every op, plus a table of ten expected-refusal cases including `move_block` after-itself, `duplicate_slide` with an empty id list, and an out-of-vocabulary template) — genuinely verifies behavior, not just shape. - The two-pass "validate a model, then apply" design in `ops.rs` is carefully mirrored between `Model::check` and `apply_one` for every op (spot-checked `move_slide`/`move_block`'s remove-then-resolve-`after` ordering, which is subtle and correctly matched in both passes). - `internal/core/core.go`'s trap/timeout containment (bounded guest calls, `WithCloseOnContextDone`, log-replay rebuild, `RecoveredError` vs `RejectedError`) is thorough and clearly reasons about wasm trap semantics (no unwind under `panic = "abort"`, poisoned `RefCell` guards). - `abi.rs` correctly follows the "no borrow held across a yrs call" and bounds-checking rules the brief calls out, and is careful about the 0-handle wraparound sentinel. ### Issues #### Critical (Must Fix) None. #### Important (Should Fix) 1. **`columns.children` referential integrity is unenforced.** Per spec §2.4, `children` must be block ids "in this slide's blocks." Neither `Model::check` (`core/src/ops.rs:1780-1990`) nor `apply_one` validates this for `add_block` or `set_block`, so an op batch can write dangling/cross-slide references that the core will silently accept. `duplicate_slide`'s `rewrite_ids` (`core/src/ops.rs:2364-2402`) only remaps recognized ids and leaves anything else untouched, compounding the gap. This is exactly the edge case the review brief called out; it should either be validated (reject unknown ids) or explicitly deferred with a comment explaining why v1 doesn't check it. 2. **`ops.rs` at 1089 lines mixes several responsibilities**: op parsing/deserialization (`core/src/ops.rs:127-262`), the two-pass validation model (`:264-490`), layout-conversion geometry (`:822-905`, `to_canvas`/`to_flow`/`region_rect`), slide-duplication id-rewriting (`:945-990`), and the actual mutation pass (`:993-1089`... err through end). Internally it's well-organized with clear section comments, but the layout-conversion math in particular is a self-contained algorithm (spec §2.3) that would be easier to test and reason about in its own module (e.g. `layout.rs`) rather than folded into the general ops-application file. Not a correctness problem today, but a maintainability cost as more ops get added later. #### Minor (Nice to Have) 1. `core/vendor/fastrand-2.5.0/Cargo.toml`'s hand-written patch comment (lines ~79-81) still says "docs-wt-core/vendor/README.md" and "docs-core" instead of the slides equivalents — confirmed this is present verbatim in Docs' own original too, so it's an artifact of the copy rather than a rename miss, but worth a follow-up cleanup since every other copied file (`core/vendor/README.md`, `scripts/build-wasm.sh`) was correctly renamed. 2. `scripts/build-wasm.sh`'s comment about "native tooling under `src/bin` (e.g. `emit_update`, used from the host side of `wire-gate.sh`)" describes Docs' infrastructure (a `wire-gate.sh` and `src/bin/emit_update`) that doesn't exist in this repo — copied verbatim from Docs without adjustment. Harmless (it's just an explanatory comment and the underlying `--lib`-only reasoning still applies), but slightly confusing for a future reader of this repo. 3. `set_block`'s table-resize logic (`core/src/ops.rs:2597-2611`) truncates/appends at the tail of the flat row-major `cells` array. This literally matches the constraint's wording ("truncates or appends"), but a `cols`-only change (as opposed to `rows`-only) will scramble which stored cell content ends up under which new row/column, since the resize doesn't reflow per-row. Worth a code comment noting this is a known, spec-sanctioned limitation, so a future reader doesn't mistake it for an oversight. ### Assessment **Task quality:** Approved **Reasoning:** The implementation is thorough, exceptionally well-documented, and independently verified against the real `yrs` source and Docs' reference files; every required ABI export, op, view field, and Go containment behavior is present and tested well beyond the brief's minimum. The one genuine gap (unenforced `columns.children` referential integrity) is a data-correctness soft spot rather than a crash or spec-shape violation, and the `ops.rs` file-size/separation-of-concerns point is a maintainability suggestion, not a defect — neither blocks merging this task's slice.</result> <usage><subagent_tokens>179320</subagent_tokens><tool_uses>52</tool_uses><duration_ms>418710</duration_ms></usage> </task-notification>
Attributed to this commit: 13.6 g CO₂e (1688834 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on worktree-slice-1, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.
diff --git a/core/src/layout.rs b/core/src/layout.rs| new file mode 100644 |
| index 0000000..1104550 |
| --- /dev/null |
| +++ b/core/src/layout.rs |
| @@ -0,0 +1,327 @@ |
| +//! Layout conversion between a slide's two modes (spec §2.3). |
| +//! |
| +//! A `flow` slide places blocks by `region` (`heading`, `body`, `aside`, |
| +//! `media`); a `canvas` slide places them by an absolute `box` and `z`. |
| +//! Switching a slide's `layout` (`ops::apply_one`'s `SetSlide` arm) calls |
| +//! [`to_canvas`] or [`to_flow`] here to carry blocks across, lossily in |
| +//! both directions as the spec says. Everything else in `ops.rs` — op |
| +//! parsing, the validation model, id rewriting, mutation — stays there; |
| +//! this module is just the geometry. |
| + |
| +use crate::ops::blocks_of; |
| +use crate::schema; |
| +use std::collections::HashMap; |
| +use yrs::types::AsPrelim; |
| +use yrs::{Any, Array, Map, MapRef, ReadTxn, TransactionMut}; |
| + |
| +/// The region a block lands in when nobody said: headings in `heading`, |
| +/// images in `media`, the rest in `body`. |
| +pub(crate) fn default_region(kind: &str) -> &'static str { |
| + match kind { |
| + "heading" => "heading", |
| + "image" => "media", |
| + _ => "body", |
| + } |
| +} |
| + |
| +/// The frame fraction each region occupies: `(x, y, w, h)`. `body` |
| +/// narrows to make room when the slide has an `aside`. |
| +pub(crate) fn region_rect(region: &str, has_aside: bool) -> (f64, f64, f64, f64) { |
| + match region { |
| + "heading" => (0.06, 0.08, 0.88, 0.18), |
| + "aside" => (0.54, 0.30, 0.40, 0.62), |
| + "media" => (0.50, 0.08, 0.44, 0.84), |
| + _ if has_aside => (0.06, 0.30, 0.44, 0.62), |
| + _ => (0.06, 0.30, 0.88, 0.62), |
| + } |
| +} |
| + |
| +fn region_of<T: ReadTxn>(txn: &T, block: &MapRef) -> String { |
| + match schema::get_string(txn, block, "region") { |
| + Some(r) if schema::REGIONS.contains(&r.as_str()) => r, |
| + _ => { |
| + default_region(&schema::get_string(txn, block, "type").unwrap_or_default()).to_string() |
| + } |
| + } |
| +} |
| + |
| +/// Flow to canvas: every block gets the box its region gave it, blocks |
| +/// sharing a region split its height equally in order, `z` is the |
| +/// array index, and `region` goes. |
| +pub(crate) fn to_canvas(txn: &mut TransactionMut, slide: &MapRef) { |
| + let blocks = blocks_of(txn, slide); |
| + let maps = schema::maps_of(txn, &blocks); |
| + let regions: Vec<String> = maps.iter().map(|(_, m)| region_of(txn, m)).collect(); |
| + let has_aside = regions.iter().any(|r| r == "aside"); |
| + let mut counts: HashMap<&str, usize> = HashMap::new(); |
| + for r in ®ions { |
| + *counts.entry(r.as_str()).or_default() += 1; |
| + } |
| + let mut seen: HashMap<&str, usize> = HashMap::new(); |
| + for ((i, m), region) in maps.iter().zip(®ions) { |
| + let n = counts[region.as_str()] as f64; |
| + let k = seen.entry(region.as_str()).or_default(); |
| + let (x, y, w, h) = region_rect(region, has_aside); |
| + let slot = h / n; |
| + let bx = schema::box_json(x, y + slot * (*k as f64), w, slot); |
| + *k += 1; |
| + m.insert(txn, "box", schema::json_to_any(&bx)); |
| + m.insert(txn, "z", Any::Number(*i as f64)); |
| + m.remove(txn, "region"); |
| + } |
| +} |
| + |
| +/// Canvas to flow: boxes go, regions come from position (a heading in |
| +/// the top quarter is `heading`, an image is `media`, the rest `body`), |
| +/// and the array is reordered top to bottom, left to right. |
| +pub(crate) fn to_flow(txn: &mut TransactionMut, slide: &MapRef) { |
| + let blocks = blocks_of(txn, slide); |
| + let maps = schema::maps_of(txn, &blocks); |
| + let mut order: Vec<(f64, f64, usize)> = Vec::with_capacity(maps.len()); |
| + for (pos, (_, m)) in maps.iter().enumerate() { |
| + let kind = schema::get_string(txn, m, "type").unwrap_or_default(); |
| + let bx = schema::get_object(txn, m, "box"); |
| + let coord = |k: &str, default: f64| { |
| + bx.as_ref() |
| + .and_then(|b| b.get(k)) |
| + .and_then(serde_json::Value::as_f64) |
| + .unwrap_or(default) |
| + }; |
| + let (x, y) = (coord("x", 0.06), coord("y", 0.30)); |
| + let region = match kind.as_str() { |
| + "heading" if y < 0.25 => "heading", |
| + "image" => "media", |
| + _ => "body", |
| + }; |
| + m.insert(txn, "region", Any::from(region)); |
| + m.remove(txn, "box"); |
| + m.remove(txn, "z"); |
| + order.push((y, x, pos)); |
| + } |
| + order.sort_by(|a, b| { |
| + a.0.partial_cmp(&b.0) |
| + .unwrap_or(std::cmp::Ordering::Equal) |
| + .then(a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)) |
| + }); |
| + if order.iter().enumerate().all(|(i, (_, _, pos))| i == *pos) { |
| + return; |
| + } |
| + let prelims: Vec<yrs::MapPrelim> = order |
| + .iter() |
| + .map(|(_, _, pos)| maps[*pos].1.as_prelim(txn)) |
| + .collect(); |
| + let len = blocks.len(txn); |
| + blocks.remove_range(txn, 0, len); |
| + for p in prelims { |
| + blocks.push_back(txn, p); |
| + } |
| +} |
| + |
| +#[cfg(test)] |
| +mod tests { |
| + use super::*; |
| + use yrs::{Doc, Options, Transact}; |
| + |
| + fn doc() -> Doc { |
| + Doc::with_options(Options::with_guid_and_client_id("layout-test".into(), 1)) |
| + } |
| + |
| + /// A slide with `blocks` built through `schema::block_prelim`, so |
| + /// these tests exercise the same shapes `ops.rs` writes. |
| + #[allow(clippy::type_complexity)] |
| + fn slide_with_blocks( |
| + txn: &mut TransactionMut, |
| + root: &yrs::ArrayRef, |
| + blocks: Vec<( |
| + &str, |
| + &str, |
| + Option<&str>, |
| + Option<(f64, f64, f64, f64)>, |
| + Option<f64>, |
| + )>, |
| + ) -> MapRef { |
| + let slide = root.push_back(txn, yrs::MapPrelim::default()); |
| + let arr = slide.insert(txn, "blocks", yrs::ArrayPrelim::default()); |
| + let empty_props = serde_json::Map::new(); |
| + for (id, kind, region, bx, z) in blocks { |
| + let box_json = bx.map(|(x, y, w, h)| schema::box_json(x, y, w, h)); |
| + let prelim = schema::block_prelim( |
| + id, |
| + kind, |
| + &empty_props, |
| + region, |
| + box_json.as_ref(), |
| + z, |
| + None, |
| + None, |
| + ); |
| + arr.push_back(txn, prelim); |
| + } |
| + slide |
| + } |
| + |
| + // --- the box table (§2.3) --- |
| + |
| + #[test] |
| + fn heading_region_rect() { |
| + assert_eq!(region_rect("heading", false), (0.06, 0.08, 0.88, 0.18)); |
| + assert_eq!(region_rect("heading", true), (0.06, 0.08, 0.88, 0.18)); |
| + } |
| + |
| + #[test] |
| + fn aside_region_rect() { |
| + assert_eq!(region_rect("aside", false), (0.54, 0.30, 0.40, 0.62)); |
| + assert_eq!(region_rect("aside", true), (0.54, 0.30, 0.40, 0.62)); |
| + } |
| + |
| + #[test] |
| + fn media_region_rect() { |
| + assert_eq!(region_rect("media", false), (0.50, 0.08, 0.44, 0.84)); |
| + assert_eq!(region_rect("media", true), (0.50, 0.08, 0.44, 0.84)); |
| + } |
| + |
| + #[test] |
| + fn body_region_rect_narrows_for_an_aside() { |
| + assert_eq!(region_rect("body", false), (0.06, 0.30, 0.88, 0.62)); |
| + assert_eq!(region_rect("body", true), (0.06, 0.30, 0.44, 0.62)); |
| + } |
| + |
| + #[test] |
| + fn default_region_by_kind() { |
| + assert_eq!(default_region("heading"), "heading"); |
| + assert_eq!(default_region("image"), "media"); |
| + assert_eq!(default_region("text"), "body"); |
| + assert_eq!(default_region("columns"), "body"); |
| + } |
| + |
| + // --- flow to canvas --- |
| + |
| + #[test] |
| + fn to_canvas_splits_a_shared_region_equally_and_sets_z_by_index() { |
| + let d = doc(); |
| + let root = d.get_or_insert_array("slides"); |
| + let mut txn = d.transact_mut(); |
| + let slide = slide_with_blocks( |
| + &mut txn, |
| + &root, |
| + vec![ |
| + ("b1", "heading", Some("heading"), None, None), |
| + ("b2", "text", Some("body"), None, None), |
| + ("b3", "text", Some("body"), None, None), |
| + ], |
| + ); |
| + to_canvas(&mut txn, &slide); |
| + |
| + let blocks = schema::get_array(&txn, &slide, "blocks").unwrap(); |
| + let maps = schema::maps_of(&txn, &blocks); |
| + |
| + let heading_box = schema::get_object(&txn, &maps[0].1, "box").unwrap(); |
| + assert_eq!(heading_box, schema::box_json(0.06, 0.08, 0.88, 0.18)); |
| + assert_eq!(schema::get_number(&txn, &maps[0].1, "z"), Some(0.0)); |
| + assert!(schema::get_string(&txn, &maps[0].1, "region").is_none()); |
| + |
| + // Two blocks share `body` (h=0.62): each gets half, stacked. |
| + let b2_box = schema::get_object(&txn, &maps[1].1, "box").unwrap(); |
| + assert_eq!(b2_box, schema::box_json(0.06, 0.30, 0.88, 0.31)); |
| + assert_eq!(schema::get_number(&txn, &maps[1].1, "z"), Some(1.0)); |
| + |
| + let b3_box = schema::get_object(&txn, &maps[2].1, "box").unwrap(); |
| + assert_eq!(b3_box, schema::box_json(0.06, 0.61, 0.88, 0.31)); |
| + assert_eq!(schema::get_number(&txn, &maps[2].1, "z"), Some(2.0)); |
| + } |
| + |
| + #[test] |
| + fn to_canvas_narrows_body_when_an_aside_is_present() { |
| + let d = doc(); |
| + let root = d.get_or_insert_array("slides"); |
| + let mut txn = d.transact_mut(); |
| + let slide = slide_with_blocks( |
| + &mut txn, |
| + &root, |
| + vec![ |
| + ("b1", "text", Some("body"), None, None), |
| + ("b2", "text", Some("aside"), None, None), |
| + ], |
| + ); |
| + to_canvas(&mut txn, &slide); |
| + let blocks = schema::get_array(&txn, &slide, "blocks").unwrap(); |
| + let maps = schema::maps_of(&txn, &blocks); |
| + let body_box = schema::get_object(&txn, &maps[0].1, "box").unwrap(); |
| + assert_eq!(body_box, schema::box_json(0.06, 0.30, 0.44, 0.62)); |
| + } |
| + |
| + // --- canvas to flow --- |
| + |
| + #[test] |
| + fn to_flow_classifies_by_position_and_kind() { |
| + let d = doc(); |
| + let root = d.get_or_insert_array("slides"); |
| + let mut txn = d.transact_mut(); |
| + let slide = slide_with_blocks( |
| + &mut txn, |
| + &root, |
| + vec![ |
| + ("b1", "heading", None, Some((0.06, 0.08, 0.88, 0.18)), Some(0.0)), |
| + ("b2", "image", None, Some((0.5, 0.5, 0.2, 0.2)), Some(1.0)), |
| + ("b3", "text", None, Some((0.06, 0.6, 0.5, 0.3)), Some(2.0)), |
| + ], |
| + ); |
| + to_flow(&mut txn, &slide); |
| + let blocks = schema::get_array(&txn, &slide, "blocks").unwrap(); |
| + let maps = schema::maps_of(&txn, &blocks); |
| + assert_eq!(schema::get_string(&txn, &maps[0].1, "region").as_deref(), Some("heading")); |
| + assert_eq!(schema::get_string(&txn, &maps[1].1, "region").as_deref(), Some("media")); |
| + assert_eq!(schema::get_string(&txn, &maps[2].1, "region").as_deref(), Some("body")); |
| + for (_, m) in &maps { |
| + assert!(schema::get_object(&txn, m, "box").is_none()); |
| + assert!(schema::get_number(&txn, m, "z").is_none()); |
| + } |
| + } |
| + |
| + #[test] |
| + fn to_flow_orders_y_then_x() { |
| + let d = doc(); |
| + let root = d.get_or_insert_array("slides"); |
| + let mut txn = d.transact_mut(); |
| + // Written in an order the y-then-x sort must undo. |
| + let slide = slide_with_blocks( |
| + &mut txn, |
| + &root, |
| + vec![ |
| + ("bottom-right", "text", None, Some((0.6, 0.6, 0.2, 0.2)), Some(0.0)), |
| + ("top-right", "text", None, Some((0.6, 0.1, 0.2, 0.2)), Some(1.0)), |
| + ("top-left", "text", None, Some((0.1, 0.1, 0.2, 0.2)), Some(2.0)), |
| + ("bottom-left", "text", None, Some((0.1, 0.6, 0.2, 0.2)), Some(3.0)), |
| + ], |
| + ); |
| + to_flow(&mut txn, &slide); |
| + let blocks = schema::get_array(&txn, &slide, "blocks").unwrap(); |
| + let ids: Vec<String> = schema::maps_of(&txn, &blocks) |
| + .iter() |
| + .map(|(_, m)| schema::get_string(&txn, m, "id").unwrap()) |
| + .collect(); |
| + assert_eq!(ids, vec!["top-left", "top-right", "bottom-left", "bottom-right"]); |
| + } |
| + |
| + #[test] |
| + fn to_flow_is_a_no_op_when_already_in_order() { |
| + let d = doc(); |
| + let root = d.get_or_insert_array("slides"); |
| + let mut txn = d.transact_mut(); |
| + let slide = slide_with_blocks( |
| + &mut txn, |
| + &root, |
| + vec![ |
| + ("a", "text", None, Some((0.1, 0.1, 0.2, 0.2)), Some(0.0)), |
| + ("b", "text", None, Some((0.1, 0.6, 0.2, 0.2)), Some(1.0)), |
| + ], |
| + ); |
| + to_flow(&mut txn, &slide); |
| + let blocks = schema::get_array(&txn, &slide, "blocks").unwrap(); |
| + let ids: Vec<String> = schema::maps_of(&txn, &blocks) |
| + .iter() |
| + .map(|(_, m)| schema::get_string(&txn, m, "id").unwrap()) |
| + .collect(); |
| + assert_eq!(ids, vec!["a", "b"]); |
| + } |
| +} |
diff --git a/core/src/lib.rs b/core/src/lib.rs| index 204cd63..cccdfd8 100644 |
| --- a/core/src/lib.rs |
| +++ b/core/src/lib.rs |
| @@ -62,6 +62,7 @@ |
| //! supplying both explicitly (see above) rather than relying on `fastrand` |
| //! to fail loudly when it can't — it won't. |
| pub mod abi; |
| +mod layout; |
| pub mod markdown; |
| mod ops; |
| mod schema; |
diff --git a/core/src/ops.rs b/core/src/ops.rs| index 52de59d..23b16cb 100644 |
| --- a/core/src/ops.rs |
| +++ b/core/src/ops.rs |
| @@ -34,6 +34,7 @@ |
| //! so no observer, no captured buffer, and nothing that could see a |
| //! different transaction's changes. |
| +use crate::layout; |
| use crate::markdown; |
| use crate::schema::{self, Paragraph, Roots}; |
| use crate::CoreError; |
| @@ -242,6 +243,12 @@ fn table_side(props: &Props, key: &str) -> Result<Option<i64>, CoreError> { |
| struct BlockModel { |
| id: String, |
| kind: String, |
| + /// `Some(children)` for a `columns` block: the block ids its |
| + /// `props.children` currently names, kept in step through the batch |
| + /// so `remove_block` can drop a removed id from it and a later |
| + /// `set_block` on the same columns block sees the drop (spec §2.4). |
| + /// `None` for every other kind. |
| + children: Option<Vec<String>>, |
| } |
| struct SlideModel { |
| @@ -263,9 +270,25 @@ impl Model { |
| .map(|a| { |
| schema::maps_of(txn, &a) |
| .into_iter() |
| - .map(|(_, b)| BlockModel { |
| - id: schema::get_string(txn, &b, "id").unwrap_or_default(), |
| - kind: schema::get_string(txn, &b, "type").unwrap_or_default(), |
| + .map(|(_, b)| { |
| + let kind = |
| + schema::get_string(txn, &b, "type").unwrap_or_default(); |
| + let children = (kind == "columns").then(|| { |
| + schema::get_object(txn, &b, "props") |
| + .and_then(|p| p.get("children").cloned()) |
| + .and_then(|v| v.as_array().cloned()) |
| + .map(|arr| { |
| + arr.iter() |
| + .filter_map(|c| c.as_str().map(str::to_string)) |
| + .collect() |
| + }) |
| + .unwrap_or_default() |
| + }); |
| + BlockModel { |
| + id: schema::get_string(txn, &b, "id").unwrap_or_default(), |
| + kind, |
| + children, |
| + } |
| }) |
| .collect() |
| }) |
| @@ -366,6 +389,16 @@ impl Model { |
| if distinct.len() != n || new_block_ids.iter().any(String::is_empty) { |
| return fail("duplicate_slide: new_block_ids must be distinct and non-empty"); |
| } |
| + // Old block id -> new, so a duplicated `columns` block's |
| + // children (already validated to name blocks on this |
| + // slide) follow the copy exactly like `rewrite_ids` |
| + // remaps them at apply time. |
| + let id_map: HashMap<&str, &str> = self.slides[i] |
| + .blocks |
| + .iter() |
| + .map(|b| b.id.as_str()) |
| + .zip(new_block_ids.iter().map(String::as_str)) |
| + .collect(); |
| let blocks = self.slides[i] |
| .blocks |
| .iter() |
| @@ -373,6 +406,16 @@ impl Model { |
| .map(|(b, nid)| BlockModel { |
| id: nid.clone(), |
| kind: b.kind.clone(), |
| + children: b.children.as_ref().map(|cs| { |
| + cs.iter() |
| + .map(|c| { |
| + id_map |
| + .get(c.as_str()) |
| + .map(|n| n.to_string()) |
| + .unwrap_or_else(|| c.clone()) |
| + }) |
| + .collect() |
| + }), |
| }) |
| .collect(); |
| self.slides.insert( |
| @@ -433,6 +476,14 @@ impl Model { |
| if s.blocks.iter().any(|b| &b.id == id) { |
| return fail(format!("block {id:?} already exists in slide {slide:?}")); |
| } |
| + let children = if kind == "columns" { |
| + Some(match props.get("children") { |
| + Some(v) => validate_children(s, id, v)?, |
| + None => Vec::new(), |
| + }) |
| + } else { |
| + None |
| + }; |
| let at = match after { |
| None => s.blocks.len(), |
| Some(None) => 0, |
| @@ -443,6 +494,7 @@ impl Model { |
| BlockModel { |
| id: id.clone(), |
| kind: kind.clone(), |
| + children, |
| }, |
| ); |
| Ok(()) |
| @@ -451,6 +503,14 @@ impl Model { |
| let s = self.slide_mut(slide)?; |
| let i = block_index(s, id)?; |
| s.blocks.remove(i); |
| + // Spec §2.4: a `columns` block's children may not name a |
| + // block that no longer exists — drop it rather than |
| + // reject the removal. |
| + for b in s.blocks.iter_mut() { |
| + if let Some(children) = &mut b.children { |
| + children.retain(|c| c != id); |
| + } |
| + } |
| Ok(()) |
| } |
| Op::MoveBlock { slide, id, after } => { |
| @@ -488,6 +548,12 @@ impl Model { |
| table_side(p, "cols")?; |
| } |
| } |
| + if s.blocks[i].kind == "columns" { |
| + if let Some(children_value) = props.as_ref().and_then(|p| p.get("children")) { |
| + let children = validate_children(s, id, children_value)?; |
| + s.blocks[i].children = Some(children); |
| + } |
| + } |
| Ok(()) |
| } |
| Op::SetText { slide, id, .. } => { |
| @@ -534,6 +600,37 @@ fn block_index(s: &SlideModel, id: &str) -> Result<usize, CoreError> { |
| .ok_or_else(|| CoreError::Ops(format!("no block {id:?} in slide {:?}", s.id))) |
| } |
| +/// A `columns` block's `props.children` (spec §2.4): an array of block |
| +/// ids, each naming a block on `s` other than `self_id`. `null` clears |
| +/// (an absent-props-key removal), so it validates to no children. |
| +fn validate_children(s: &SlideModel, self_id: &str, value: &Value) -> Result<Vec<String>, CoreError> { |
| + if value.is_null() { |
| + return Ok(Vec::new()); |
| + } |
| + let items = value |
| + .as_array() |
| + .ok_or_else(|| CoreError::Ops("columns children must be an array of block ids".into()))?; |
| + let mut out = Vec::with_capacity(items.len()); |
| + for item in items { |
| + let cid = item.as_str().ok_or_else(|| { |
| + CoreError::Ops("columns children must be an array of block ids".into()) |
| + })?; |
| + if cid == self_id { |
| + return fail(format!( |
| + "columns {self_id:?} cannot name itself in children" |
| + )); |
| + } |
| + if !s.blocks.iter().any(|b| b.id == cid) { |
| + return fail(format!( |
| + "columns children names {cid:?}, not a block on slide {:?}", |
| + s.id |
| + )); |
| + } |
| + out.push(cid.to_string()); |
| + } |
| + Ok(out) |
| +} |
| + |
| // --- the applying pass --- |
| fn slide_map(txn: &TransactionMut, roots: &Roots, id: &str) -> Result<(u32, MapRef), CoreError> { |
| @@ -542,7 +639,7 @@ fn slide_map(txn: &TransactionMut, roots: &Roots, id: &str) -> Result<(u32, MapR |
| } |
| /// The slide's block array, created if a peer wrote a slide without one. |
| -fn blocks_of(txn: &mut TransactionMut, slide: &MapRef) -> ArrayRef { |
| +pub(crate) fn blocks_of(txn: &mut TransactionMut, slide: &MapRef) -> ArrayRef { |
| match schema::get_array(txn, slide, "blocks") { |
| Some(a) => a, |
| None => slide.insert(txn, "blocks", yrs::ArrayPrelim::default()), |
| @@ -557,6 +654,32 @@ fn block_map( |
| schema::find_by_id(txn, blocks, id).ok_or_else(|| CoreError::Ops(format!("no block {id:?}"))) |
| } |
| +/// Drops `removed_id` from every `columns` block's `props.children` in |
| +/// `blocks` (spec §2.4: children may not name a block that no longer |
| +/// exists — `remove_block` prunes rather than rejects). |
| +fn remove_from_columns_children(txn: &mut TransactionMut, blocks: &ArrayRef, removed_id: &str) { |
| + for (_, m) in schema::maps_of(txn, blocks) { |
| + if schema::get_string(txn, &m, "type").as_deref() != Some("columns") { |
| + continue; |
| + } |
| + let Some(pm) = schema::get_map(txn, &m, "props") else { |
| + continue; |
| + }; |
| + let Some(Value::Array(children)) = schema::map_to_json(txn, &pm).get("children").cloned() |
| + else { |
| + continue; |
| + }; |
| + if !children.iter().any(|c| c.as_str() == Some(removed_id)) { |
| + continue; |
| + } |
| + let filtered: Vec<Value> = children |
| + .into_iter() |
| + .filter(|c| c.as_str() != Some(removed_id)) |
| + .collect(); |
| + pm.insert(txn, "children", schema::json_to_any(&Value::Array(filtered))); |
| + } |
| +} |
| + |
| fn fragment_of(txn: &mut TransactionMut, map: &MapRef, key: &str) -> yrs::XmlFragmentRef { |
| match schema::get_fragment(txn, map, key) { |
| Some(f) => f, |
| @@ -600,111 +723,6 @@ fn text_for(kind: &str, markdown: &str) -> Vec<Paragraph> { |
| } |
| } |
| -// --- layout conversion (§2.3) --- |
| - |
| -/// The region a block lands in when nobody said: headings in `heading`, |
| -/// images in `media`, the rest in `body`. |
| -fn default_region(kind: &str) -> &'static str { |
| - match kind { |
| - "heading" => "heading", |
| - "image" => "media", |
| - _ => "body", |
| - } |
| -} |
| - |
| -/// The frame fraction each region occupies: `(x, y, w, h)`. `body` |
| -/// narrows to make room when the slide has an `aside`. |
| -fn region_rect(region: &str, has_aside: bool) -> (f64, f64, f64, f64) { |
| - match region { |
| - "heading" => (0.06, 0.08, 0.88, 0.18), |
| - "aside" => (0.54, 0.30, 0.40, 0.62), |
| - "media" => (0.50, 0.08, 0.44, 0.84), |
| - _ if has_aside => (0.06, 0.30, 0.44, 0.62), |
| - _ => (0.06, 0.30, 0.88, 0.62), |
| - } |
| -} |
| - |
| -fn region_of<T: ReadTxn>(txn: &T, block: &MapRef) -> String { |
| - match schema::get_string(txn, block, "region") { |
| - Some(r) if schema::REGIONS.contains(&r.as_str()) => r, |
| - _ => { |
| - default_region(&schema::get_string(txn, block, "type").unwrap_or_default()).to_string() |
| - } |
| - } |
| -} |
| - |
| -/// Flow to canvas: every block gets the box its region gave it, blocks |
| -/// sharing a region split its height equally in order, `z` is the |
| -/// array index, and `region` goes. |
| -fn to_canvas(txn: &mut TransactionMut, slide: &MapRef) { |
| - let blocks = blocks_of(txn, slide); |
| - let maps = schema::maps_of(txn, &blocks); |
| - let regions: Vec<String> = maps.iter().map(|(_, m)| region_of(txn, m)).collect(); |
| - let has_aside = regions.iter().any(|r| r == "aside"); |
| - let mut counts: HashMap<&str, usize> = HashMap::new(); |
| - for r in ®ions { |
| - *counts.entry(r.as_str()).or_default() += 1; |
| - } |
| - let mut seen: HashMap<&str, usize> = HashMap::new(); |
| - for ((i, m), region) in maps.iter().zip(®ions) { |
| - let n = counts[region.as_str()] as f64; |
| - let k = seen.entry(region.as_str()).or_default(); |
| - let (x, y, w, h) = region_rect(region, has_aside); |
| - let slot = h / n; |
| - let bx = schema::box_json(x, y + slot * (*k as f64), w, slot); |
| - *k += 1; |
| - m.insert(txn, "box", schema::json_to_any(&bx)); |
| - m.insert(txn, "z", Any::Number(*i as f64)); |
| - m.remove(txn, "region"); |
| - } |
| -} |
| - |
| -/// Canvas to flow: boxes go, regions come from position (a heading in |
| -/// the top quarter is `heading`, an image is `media`, the rest `body`), |
| -/// and the array is reordered top to bottom, left to right. |
| -fn to_flow(txn: &mut TransactionMut, slide: &MapRef) { |
| - let blocks = blocks_of(txn, slide); |
| - let maps = schema::maps_of(txn, &blocks); |
| - let mut order: Vec<(f64, f64, usize)> = Vec::with_capacity(maps.len()); |
| - for (pos, (_, m)) in maps.iter().enumerate() { |
| - let kind = schema::get_string(txn, m, "type").unwrap_or_default(); |
| - let bx = schema::get_object(txn, m, "box"); |
| - let coord = |k: &str, default: f64| { |
| - bx.as_ref() |
| - .and_then(|b| b.get(k)) |
| - .and_then(Value::as_f64) |
| - .unwrap_or(default) |
| - }; |
| - let (x, y) = (coord("x", 0.06), coord("y", 0.30)); |
| - let region = match kind.as_str() { |
| - "heading" if y < 0.25 => "heading", |
| - "image" => "media", |
| - _ => "body", |
| - }; |
| - m.insert(txn, "region", Any::from(region)); |
| - m.remove(txn, "box"); |
| - m.remove(txn, "z"); |
| - order.push((y, x, pos)); |
| - } |
| - order.sort_by(|a, b| { |
| - a.0.partial_cmp(&b.0) |
| - .unwrap_or(std::cmp::Ordering::Equal) |
| - .then(a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)) |
| - }); |
| - if order.iter().enumerate().all(|(i, (_, _, pos))| i == *pos) { |
| - return; |
| - } |
| - let prelims: Vec<yrs::MapPrelim> = order |
| - .iter() |
| - .map(|(_, _, pos)| maps[*pos].1.as_prelim(txn)) |
| - .collect(); |
| - let len = blocks.len(txn); |
| - blocks.remove_range(txn, 0, len); |
| - for p in prelims { |
| - blocks.push_back(txn, p); |
| - } |
| -} |
| - |
| // --- duplicate --- |
| /// Gives a copied slide its new ids: the slide's own, each block's, and |
| @@ -829,8 +847,8 @@ fn apply_one(txn: &mut TransactionMut, roots: &Roots, op: &Op) -> Result<(), Cor |
| schema::get_string(txn, &m, "layout").unwrap_or_else(|| "flow".into()); |
| if *l != current { |
| match l.as_str() { |
| - "canvas" => to_canvas(txn, &m), |
| - _ => to_flow(txn, &m), |
| + "canvas" => layout::to_canvas(txn, &m), |
| + _ => layout::to_flow(txn, &m), |
| } |
| m.insert(txn, "layout", Any::from(l.as_str())); |
| } |
| @@ -892,10 +910,10 @@ fn apply_one(txn: &mut TransactionMut, roots: &Roots, op: &Op) -> Result<(), Cor |
| } else { |
| None |
| }; |
| - let region = region.as_deref().unwrap_or_else(|| default_region(kind)); |
| + let region = region.as_deref().unwrap_or_else(|| layout::default_region(kind)); |
| let (region_key, box_value, z_value) = if canvas { |
| let bx = bx.map(|b| b.json()).unwrap_or_else(|| { |
| - let (x, y, w, h) = region_rect(region, false); |
| + let (x, y, w, h) = layout::region_rect(region, false); |
| schema::box_json(x, y, w, h) |
| }); |
| (None, Some(bx), Some(z.unwrap_or(at as f64))) |
| @@ -919,6 +937,7 @@ fn apply_one(txn: &mut TransactionMut, roots: &Roots, op: &Op) -> Result<(), Cor |
| let blocks = blocks_of(txn, &s); |
| let (i, _) = block_map(txn, &blocks, id)?; |
| blocks.remove(txn, i); |
| + remove_from_columns_children(txn, &blocks, id); |
| } |
| Op::MoveBlock { slide, id, after } => { |
| let (_, s) = slide_map(txn, roots, slide)?; |
| @@ -950,6 +969,14 @@ fn apply_one(txn: &mut TransactionMut, roots: &Roots, op: &Op) -> Result<(), Cor |
| } |
| } |
| let is_table = schema::get_string(txn, &m, "type").as_deref() == Some("table"); |
| + // `cells` is row-major and flat, so growing/shrinking |
| + // only appends to or truncates the end of it -- a |
| + // cols-only change (rows unchanged) reflows every row's |
| + // cells rather than inserting/removing per row, which |
| + // shuffles existing cell text across the grid. Spec |
| + // §2.4 sanctions this as a v1 limitation; a person |
| + // resizing columns should expect to recheck cell |
| + // contents. |
| if is_table && (p.contains_key("rows") || p.contains_key("cols")) { |
| let rows = schema::get_number(txn, &pm, "rows") |
| .unwrap_or(schema::DEFAULT_TABLE_ROWS as f64) |
diff --git a/core/tests/ops.rs b/core/tests/ops.rs| index 953c029..2d2e03e 100644 |
| --- a/core/tests/ops.rs |
| +++ b/core/tests/ops.rs |
| @@ -385,3 +385,136 @@ fn remaining_ops_roundtrip() { |
| } |
| assert_eq!(view_json(&a), view_json(&b)); |
| } |
| + |
| +/// Spec §2.4: a `columns` block's `children` names blocks in the same |
| +/// slide. `add_block` and `set_block` must reject a child that isn't. |
| +#[test] |
| +fn columns_children_must_name_blocks_on_the_slide() { |
| + let d = doc(9); |
| + let setup = r#"[ |
| + {"op":"add_slide","id":"s1","layout":"flow"}, |
| + {"op":"add_slide","id":"s2","layout":"flow"}, |
| + {"op":"add_block","slide":"s1","id":"b1","type":"heading","markdown":"H"}, |
| + {"op":"add_block","slide":"s1","id":"b2","type":"text","markdown":"T"}, |
| + {"op":"add_block","slide":"s2","id":"c1","type":"text","markdown":"C"} |
| + ]"#; |
| + apply_ops(&d, setup).expect("setup applies"); |
| + let before = view_json(&d); |
| + |
| + // An id that names no block anywhere. |
| + let bad = r#"[{"op":"add_block","slide":"s1","id":"b3","type":"columns", |
| + "props":{"count":2,"children":["b1","nope"]}}]"#; |
| + let err = apply_ops(&d, bad).expect_err("unknown child id is rejected"); |
| + assert!(err.to_string().contains("nope"), "{err}"); |
| + assert_eq!(view_json(&d), before, "nothing applied"); |
| + |
| + // An id that names a block, but on a different slide. |
| + let bad = r#"[{"op":"add_block","slide":"s1","id":"b3","type":"columns", |
| + "props":{"count":2,"children":["b1","c1"]}}]"#; |
| + let err = apply_ops(&d, bad).expect_err("a block on another slide is rejected"); |
| + assert!(err.to_string().contains("c1"), "{err}"); |
| + assert_eq!(view_json(&d), before, "nothing applied"); |
| + |
| + // Valid children apply; then set_block naming the columns block |
| + // itself is rejected the same way. |
| + apply_ops( |
| + &d, |
| + r#"[{"op":"add_block","slide":"s1","id":"b3","type":"columns", |
| + "props":{"count":2,"children":["b1","b2"]}}]"#, |
| + ) |
| + .expect("valid children apply"); |
| + let before = view_json(&d); |
| + let bad = r#"[{"op":"set_block","slide":"s1","id":"b3","props":{"children":["b1","b3"]}}]"#; |
| + let err = apply_ops(&d, bad).expect_err("a columns block cannot name itself"); |
| + assert!(err.to_string().contains("b3"), "{err}"); |
| + assert_eq!(view_json(&d), before, "nothing applied"); |
| +} |
| + |
| +/// Spec §2.4: removing a block that a `columns` block's `children` |
| +/// names drops it from that list rather than rejecting the removal. |
| +#[test] |
| +fn remove_block_drops_it_from_columns_children() { |
| + let d = doc(10); |
| + apply_ops( |
| + &d, |
| + r#"[ |
| + {"op":"add_slide","id":"s1","layout":"flow"}, |
| + {"op":"add_block","slide":"s1","id":"b1","type":"heading","markdown":"H"}, |
| + {"op":"add_block","slide":"s1","id":"b2","type":"text","markdown":"T"}, |
| + {"op":"add_block","slide":"s1","id":"b3","type":"columns", |
| + "props":{"count":2,"children":["b1","b2"]}} |
| + ]"#, |
| + ) |
| + .expect("setup applies"); |
| + |
| + apply_ops(&d, r#"[{"op":"remove_block","slide":"s1","id":"b1"}]"#) |
| + .expect("remove applies"); |
| + let v = view(&d); |
| + let columns = v["slides"][0]["blocks"] |
| + .as_array() |
| + .unwrap() |
| + .iter() |
| + .find(|b| b["id"] == "b3") |
| + .expect("columns block still there"); |
| + assert_eq!( |
| + columns["props"]["children"], |
| + serde_json::json!(["b2"]), |
| + "the removed block is dropped from children, not the whole op rejected" |
| + ); |
| + |
| + // The model saw the drop too: a later op naming the now-removed |
| + // block is still rejected as unknown. |
| + let before = view_json(&d); |
| + let bad = r#"[{"op":"set_block","slide":"s1","id":"b3","props":{"children":["b1"]}}]"#; |
| + apply_ops(&d, bad).expect_err("b1 no longer exists"); |
| + assert_eq!(view_json(&d), before); |
| +} |
| + |
| +/// Spec §2.4 + duplicate_slide: a copied `columns` block's children |
| +/// follow the copy's new block ids, and the model tracks the remap so |
| +/// later ops against the copy validate against its ids, not the |
| +/// original's. |
| +#[test] |
| +fn duplicate_slide_remaps_columns_children() { |
| + let d = doc(11); |
| + apply_ops( |
| + &d, |
| + r#"[ |
| + {"op":"add_slide","id":"s1","layout":"flow"}, |
| + {"op":"add_block","slide":"s1","id":"b1","type":"heading","markdown":"H"}, |
| + {"op":"add_block","slide":"s1","id":"b2","type":"text","markdown":"T"}, |
| + {"op":"add_block","slide":"s1","id":"b3","type":"columns", |
| + "props":{"count":2,"children":["b1","b2"]}} |
| + ]"#, |
| + ) |
| + .expect("setup applies"); |
| + |
| + apply_ops( |
| + &d, |
| + r#"[{"op":"duplicate_slide","id":"s1","new_id":"s1copy","new_block_ids":["d1","d2","d3"]}]"#, |
| + ) |
| + .expect("duplicate applies"); |
| + let v = view(&d); |
| + let copy = &v["slides"][1]; |
| + assert_eq!(copy["id"], "s1copy"); |
| + assert_eq!( |
| + copy["blocks"][2]["props"]["children"], |
| + serde_json::json!(["d1", "d2"]), |
| + "columns children follow the new ids" |
| + ); |
| + |
| + // The model tracked the remap: a later op naming the copy's new |
| + // children validates... |
| + apply_ops( |
| + &d, |
| + r#"[{"op":"set_block","slide":"s1copy","id":"d3","props":{"children":["d2","d1"]}}]"#, |
| + ) |
| + .expect("remapped children still validate"); |
| + |
| + // ...and one naming the original slide's old ids against the copy |
| + // is rejected: they aren't blocks on s1copy. |
| + let before = view_json(&d); |
| + let bad = r#"[{"op":"set_block","slide":"s1copy","id":"d3","props":{"children":["b1"]}}]"#; |
| + apply_ops(&d, bad).expect_err("the original's id does not exist on the copy"); |
| + assert_eq!(view_json(&d), before); |
| +} |
diff --git a/core/vendor/fastrand-2.5.0/Cargo.toml b/core/vendor/fastrand-2.5.0/Cargo.toml| index e21cde2..8136e27 100644 |
| --- a/core/vendor/fastrand-2.5.0/Cargo.toml |
| +++ b/core/vendor/fastrand-2.5.0/Cargo.toml |
| @@ -76,9 +76,9 @@ version = "0.10" |
| [dev-dependencies.wyhash] |
| version = "0.6" |
| -# Vendored patch (see docs-wt-core/vendor/README.md): dropped the forced |
| +# Vendored patch (see core/vendor/README.md): dropped the forced |
| # "wasm_js" feature so that a `--cfg getrandom_backend="..."` override (used |
| -# by docs-core to avoid a wasm-bindgen/JS import dependency) doesn't hit |
| +# by slides-core to avoid a wasm-bindgen/JS import dependency) doesn't hit |
| # getrandom's `Error::WEB_CRYPTO` cfg mismatch (that item only exists when |
| # the "wasm_js" backend module itself is compiled, which a cfg override to |
| # a different backend skips, but `error.rs`'s Display impl references it |
diff --git a/internal/slides/core/testdata/slides_core.wasm b/internal/slides/core/testdata/slides_core.wasm| index 450ee08..75432c3 100755 |
| Binary files a/internal/slides/core/testdata/slides_core.wasm and b/internal/slides/core/testdata/slides_core.wasm differ |
diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh| index 2e6976d..c56cdfe 100755 |
| --- a/scripts/build-wasm.sh |
| +++ b/scripts/build-wasm.sh |
| @@ -9,11 +9,11 @@ cd "$(dirname "$0")/.." |
| # for. A doc comment alone has been wrong, or gotten around, more than |
| # once; this is the actual enforcement. |
| cargo clippy --manifest-path core/Cargo.toml --all-targets -- -D warnings -D clippy::disallowed_methods |
| -# --lib only: the wasm module shipped to wazero is the library crate. Native |
| -# tooling under src/bin (e.g. emit_update, used from the host side of |
| -# wire-gate.sh) is never part of that artifact and does not define the |
| -# __getrandom_v03_custom symbol itself, so building it for wasm32 fails to |
| -# link -- it was never meant to run there. |
| +# --lib only: the wasm module shipped to wazero is the library crate. Any |
| +# native tooling this crate ever grows under src/bin would not be part of |
| +# that artifact and would not define the __getrandom_v03_custom symbol |
| +# itself, so building it for wasm32 would fail to link -- it isn't meant to |
| +# run there. |
| # |
| # --remap-path-prefix (x2): rustc embeds the absolute source path of every |
| # panic site (Location::caller()) into the binary. Left alone, that means |