From 9e82a5e7049af1c6c118e966a5c7bce56a3b43be Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 03:56:50 +0100 Subject: [PATCH] aep export: review fixes for #3278 - tree::Fixed replaces tree::fixed: a fixed layout writes every field that fits (as the old code did) and keeps the first error; finish() logs it once with the layout's description and counts it per thread, so the every-layout test cannot race with other tests. - base::bytes logs one line naming its length and fields. - masks::build_atom errors when the prototype has no Mask Shape instead of dropping the path of every mask quietly. - log sorted into the dependency list. --- crates/aep/Cargo.toml | 2 +- crates/aep/src/export/base.rs | 26 +++++----- crates/aep/src/export/fxsynth.rs | 72 ++++++++++++++-------------- crates/aep/src/export/masks.rs | 67 ++++++++++++++++++++------ crates/aep/src/export/synth.rs | 48 ++++++++++--------- crates/aep/src/export/tree.rs | 81 +++++++++++++++++++++++++------- 6 files changed, 191 insertions(+), 105 deletions(-) diff --git a/crates/aep/Cargo.toml b/crates/aep/Cargo.toml index d1fbc4a3a..3deb09de2 100644 --- a/crates/aep/Cargo.toml +++ b/crates/aep/Cargo.toml @@ -15,9 +15,9 @@ effectcraft-color = { workspace = true } effectcraft-keyframe = { workspace = true } effectcraft-project = { workspace = true } effectcraft-effects = { workspace = true } +log = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } -log = { workspace = true } [dev-dependencies] serde_json = { workspace = true } diff --git a/crates/aep/src/export/base.rs b/crates/aep/src/export/base.rs index 9da1db2ba..dfaca7321 100644 --- a/crates/aep/src/export/base.rs +++ b/crates/aep/src/export/base.rs @@ -7,24 +7,20 @@ use crate::Chunk; use crate::export::synth::{GROUP_END, tdmn, tdsn}; -use crate::export::tree::{R, fixed, put}; +use crate::export::tree::Fixed; /// A zero buffer of `len` bytes with `fields` (offset, bytes) written into it. The layouts are -/// fixed in code ([`fixed`]). +/// fixed in code ([`Fixed`]). pub(crate) fn bytes(len: usize, fields: &[(usize, &[u8])]) -> Vec { - fixed("base::bytes", try_bytes(len, fields), || { - // Which layout: its length and fields (the builders are named in the comments above). - log::error!("aep export: base::bytes layout of {len} bytes, fields at {:?}", fields.iter().map(|(at, v)| (*at, v.len())).collect::>()); - vec![0u8; len] - }) + layout(len, fields).finish(|| format!("base::bytes ({len} bytes, fields at {:?})", fields.iter().map(|(at, v)| (*at, v.len())).collect::>())) } -fn try_bytes(len: usize, fields: &[(usize, &[u8])]) -> R> { - let mut b = vec![0u8; len]; +fn layout(len: usize, fields: &[(usize, &[u8])]) -> Fixed { + let mut b = Fixed::zeroed(len); for (at, v) in fields { - put(&mut b, *at, v)?; + b.put(*at, v); } - Ok(b) + b } pub(crate) fn utf8(text: &str) -> Chunk<'static> { @@ -286,9 +282,11 @@ mod tests { #[test] fn a_field_out_of_range_is_an_error() { - assert_eq!(try_bytes(4, &[(0, &[1]), (3, &[2])]).ok(), Some(vec![1, 0, 0, 2])); - assert!(try_bytes(4, &[(3, &[1, 2])]).is_err()); - assert!(try_bytes(4, &[(usize::MAX, &[1])]).is_err()); + let ok = layout(4, &[(0, &[1]), (3, &[2])]); + assert!(ok.error().is_none()); + assert_eq!(ok.finish(String::new), [1, 0, 0, 2]); + assert!(layout(4, &[(3, &[1, 2])]).error().is_some()); + assert!(layout(4, &[(usize::MAX, &[1])]).error().is_some()); } /// The chunks After Effects refuses a file without, or crashes on (aep-export.md, "What After diff --git a/crates/aep/src/export/fxsynth.rs b/crates/aep/src/export/fxsynth.rs index e4bf002b9..cce25b6f2 100644 --- a/crates/aep/src/export/fxsynth.rs +++ b/crates/aep/src/export/fxsynth.rs @@ -12,7 +12,7 @@ use crate::Chunk; use crate::effect_map::{Conv, by_match_name}; use crate::export::fx_defs::{BUILT_IN, FxDef, PDef}; use crate::export::synth::{GROUP_END, KeyTemplate, NO_NAME, RecSpec, class, tdmn, tdsn, utf8_in}; -use crate::export::tree::{R, fixed as fixed_layout, put}; +use crate::export::tree::Fixed; use crate::{CURVE_IDENTITY, CurvesData, MeshData}; /// The layer a prototype's layer parameters name when their default is the owning layer; the @@ -48,91 +48,91 @@ fn fixed(v: f64) -> [u8; 4] { /// display 124; fixed and integer: 124 / 128 / 132 / 136, default 140, display 144). Every other /// byte is zero (the switches at 48 are not needed: s12-e2-nofl). pub(crate) fn pard(p: &PDef, popup_items: Option) -> Vec { - fixed_layout("pard", try_pard(p, popup_items), || vec![0u8; 148]) + pard_layout(p, popup_items).finish(|| format!("pard (type {})", p.code)) } -fn try_pard(p: &PDef, popup_items: Option) -> R> { - let mut b = vec![0u8; 148]; - put(&mut b, 12, &u32::from(p.code).to_be_bytes())?; +fn pard_layout(p: &PDef, popup_items: Option) -> Fixed { + let mut b = Fixed::zeroed(148); + b.put(12, &u32::from(p.code).to_be_bytes()); let d = |i: usize| p.default.get(i).copied().unwrap_or(0.0); // After Effects takes the default of a parameter without a value record from here (s8-e1: // the copies below are the ones it reads). match p.code { 10 => { - put(&mut b, 56, &d(0).to_be_bytes())?; + b.put(56, &d(0).to_be_bytes()); // The default again as `f32` at 120, the display word (decimals) at 124. - put(&mut b, 120, &(d(0) as f32).to_be_bytes())?; - put(&mut b, 124, &p.display.to_be_bytes())?; + b.put(120, &(d(0) as f32).to_be_bytes()); + b.put(124, &p.display.to_be_bytes()); } - 2 => put(&mut b, 56, &fixed(d(0)))?, + 2 => b.put(56, &fixed(d(0))), // An angle: the default again at 60. 3 => { - put(&mut b, 56, &fixed(d(0)))?; - put(&mut b, 60, &fixed(d(0)))?; + b.put(56, &fixed(d(0))); + b.put(60, &fixed(d(0))); } - 1 => put(&mut b, 56, &(d(0).round() as i32).to_be_bytes())?, + 1 => b.put(56, &(d(0).round() as i32).to_be_bytes()), // A checkbox: the default again as a byte at 60. 4 => { - put(&mut b, 56, &(d(0).round().max(0.0) as u32).to_be_bytes())?; - put(&mut b, 60, &[u8::from(d(0) != 0.0)])?; + b.put(56, &(d(0).round().max(0.0) as u32).to_be_bytes()); + b.put(60, &[u8::from(d(0) != 0.0)]); } // A popup: the default again as `u16` at 62 (the item count is at 60). 7 => { let sel = d(0).round().clamp(0.0, f64::from(u16::MAX)); - put(&mut b, 56, &(sel as u32).to_be_bytes())?; - put(&mut b, 62, &(sel as u16).to_be_bytes())?; + b.put(56, &(sel as u32).to_be_bytes()); + b.put(62, &(sel as u16).to_be_bytes()); } 6 => { // Fractions of the layer at 56 / 60 and the same in percent at 68 / 72. - put(&mut b, 56, &fixed(d(0)))?; - put(&mut b, 60, &fixed(d(1)))?; - put(&mut b, 68, &fixed(d(0) * 100.0))?; - put(&mut b, 72, &fixed(d(1) * 100.0))?; + b.put(56, &fixed(d(0))); + b.put(60, &fixed(d(1))); + b.put(68, &fixed(d(0) * 100.0)); + b.put(72, &fixed(d(1) * 100.0)); } 5 => { // ARGB at 56 and the same at 60. let c: Vec = (0..4).map(|i| d(i).round().clamp(0.0, 255.0) as u8).collect(); - put(&mut b, 56, &c)?; - put(&mut b, 60, &c)?; + b.put(56, &c); + b.put(60, &c); } _ => {} } if p.code == 7 { let n = popup_items.unwrap_or_else(|| u16::try_from(p.display).unwrap_or(0)); - put(&mut b, 60, &n.to_be_bytes())?; + b.put(60, &n.to_be_bytes()); } match p.code { 10 => { if let Some(m) = p.valid_min { - put(&mut b, 104, &(m as f32).to_be_bytes())?; + b.put(104, &(m as f32).to_be_bytes()); } if let Some(m) = p.valid_max { - put(&mut b, 108, &(m as f32).to_be_bytes())?; + b.put(108, &(m as f32).to_be_bytes()); } if let Some((lo, hi)) = p.range { - put(&mut b, 112, &(lo as f32).to_be_bytes())?; - put(&mut b, 116, &(hi as f32).to_be_bytes())?; + b.put(112, &(lo as f32).to_be_bytes()); + b.put(116, &(hi as f32).to_be_bytes()); } } 1 | 2 => { let enc = |v: f64| if p.code == 2 { fixed(v) } else { (v.round() as i32).to_be_bytes() }; if let Some(m) = p.valid_min { - put(&mut b, 124, &enc(m))?; + b.put(124, &enc(m)); } if let Some(m) = p.valid_max { - put(&mut b, 128, &enc(m))?; + b.put(128, &enc(m)); } if let Some((lo, hi)) = p.range { - put(&mut b, 132, &enc(lo))?; - put(&mut b, 136, &enc(hi))?; + b.put(132, &enc(lo)); + b.put(136, &enc(hi)); } // The default again at 140, the display word (decimals, percent) at 144. - put(&mut b, 140, &enc(d(0)))?; - put(&mut b, 144, &p.display.to_be_bytes())?; + b.put(140, &enc(d(0))); + b.put(144, &p.display.to_be_bytes()); } _ => {} } - Ok(b) + b } /// The default arbitrary data of an effect (its type-11 parameter), from the decoded layouts @@ -278,7 +278,9 @@ mod tests { fn every_parameter_definition_fits() { for fx in FX_DEFS { for (i, p) in fx.params.iter().enumerate() { - assert_eq!(try_pard(p, popup_items(fx, i)).map(|b| b.len()).ok(), Some(148), "{} {}", fx.match_name, i); + let b = pard_layout(p, popup_items(fx, i)); + assert!(b.error().is_none(), "{} {}", fx.match_name, i); + assert_eq!(b.finish(String::new).len(), 148); } } } diff --git a/crates/aep/src/export/masks.rs b/crates/aep/src/export/masks.rs index eb61b0a09..b46811500 100644 --- a/crates/aep/src/export/masks.rs +++ b/crates/aep/src/export/masks.rs @@ -21,7 +21,7 @@ use crate::export::keys::{set_key_list, timed_keys}; use crate::export::plan::{CompPlan, EaseSlot, KeyPlan, LIMIT, cut_name}; use crate::export::report::{Collector, LossCategory}; use crate::export::synth::GROUP_END; -use crate::export::tree::{R, child_data, child_mut, fixed, kids_mut, list_mut, put, put_f32, put_f64, put_i32, put_u8, put_u16, put_u32, split_group, tpl}; +use crate::export::tree::{Fixed, R, child_data, child_mut, kids_mut, list_mut, put_f32, put_f64, put_i32, put_u8, put_u16, put_u32, split_group, tpl}; /// The most vertices one mask path may have (as the importer). pub(crate) const MAX_VERTICES: usize = 65_536; @@ -365,26 +365,25 @@ const MASK_LIMIT: f64 = 32_000.0; /// s13-m1), byte 3 `0x01` (`0x09` once the path is open), the box at 4..19 (written per shape), /// byte 20 1. pub(crate) fn shph() -> Vec { - fixed("shph", try_shph(), || vec![0u8; 24]) + shph_layout().finish(|| "shph".to_string()) } -fn try_shph() -> R> { - let mut b = vec![0u8; 24]; - put(&mut b, 0, &[0xb3, 0xde, 0x02, 0x01])?; - put_u8(&mut b, 20, 1)?; - Ok(b) +fn shph_layout() -> Fixed { + let mut b = Fixed::zeroed(24); + b.put(0, &[0xb3, 0xde, 0x02, 0x01]); + b.put(20, &[1]); + b } /// The two prototype records of a keyed path (093): 64 bytes each, flags `0x0001`, the `f64` at /// 16 1 in the first (the rest is zero: its last bytes were uninitialised). -fn path_key_records() -> R> { - let mut first = vec![0u8; 64]; - let mut later = vec![0u8; 64]; - for r in [&mut first, &mut later] { - put_u16(r, 6, 0x0001)?; +fn path_key_records() -> Fixed { + let mut b = Fixed::zeroed(128); + for at in [6, 64 + 6] { + b.put(at, &0x0001u16.to_be_bytes()); } - put_f64(&mut first, 16, 1.0)?; - Ok([first, later].concat()) + b.put(16, &1.0f64.to_be_bytes()); + b } impl MaskProtos { @@ -400,7 +399,7 @@ impl MaskProtos { let path_rec = |keyed: bool| { let mut kids = vec![state(), tdsn(NO_NAME), Chunk::data(*b"tdb4", tdb4(&path, keyed, 25_600))]; if keyed { - kids.push(key_list(64, 2, fixed("keyed path", path_key_records(), || vec![0u8; 128]))); + kids.push(key_list(64, 2, path_key_records().finish(|| "the keyed path records".to_string()))); } else { kids.push(Chunk::data(*b"cdat", vec![0u8; 4])); } @@ -465,6 +464,7 @@ fn build_atom(t: &MaskProtos, m: &MaskPlan, size: (u32, u32)) -> R; 2] = match entry.try_into() { @@ -477,6 +477,7 @@ fn build_atom(t: &MaskProtos, m: &MaskPlan, size: (u32, u32)) -> R { build_shape(t, &mut node, m, size)?; + has_shape = true; true } "ADBE Mask Feather" => { @@ -511,6 +512,10 @@ fn build_atom(t: &MaskProtos, m: &MaskPlan, size: (u32, u32)) -> R, s: &ShapePlan) -> R MaskPlan { + let shape = ShapePlan { bbox: [0.0, 0.0, 1.0, 1.0], open: false, points: vec![[0.0, 0.0]; 3] }; + MaskPlan { + name: "M".into(), + mode: 1, + inverted: false, + shape, + path_keys: Vec::new(), + feather: None, + feather_keys: Vec::new(), + opacity: None, + expansion: None, + } + } + + #[test] + fn an_atom_without_its_path_is_an_error() { + let mut t = MaskProtos::build(); + assert!(build_atom(&t, &mask(), (100, 100)).is_ok()); + // Without the Mask Shape pair the path of every mask would be dropped quietly. + let kids = kids_mut(&mut t.atom).unwrap(); + let at = kids.iter().position(|c| is_tdmn(c, "ADBE Mask Shape")).unwrap(); + kids.drain(at..at + 2); + assert!(build_atom(&t, &mask(), (100, 100)).is_err()); + } +} diff --git a/crates/aep/src/export/synth.rs b/crates/aep/src/export/synth.rs index 3517c41d2..80678baa3 100644 --- a/crates/aep/src/export/synth.rs +++ b/crates/aep/src/export/synth.rs @@ -4,7 +4,7 @@ //! evidence, or zero where After Effects was shown to accept zero (the read-backs listed in //! `docs/aep-export.md`, "Built in code"). -use crate::export::tree::{R, fixed, put, put_u32}; +use crate::export::tree::{Fixed, R, put_u32}; use crate::{Body, Chunk}; /// `tdmn`: a match name padded to 40 bytes (at most 39 bytes of the name, so that a NUL remains). @@ -123,12 +123,12 @@ impl RecSpec { /// word and the constant doubles at 16..56 are written; After Effects reads the other bytes as /// zero (read-back e1-ztdb4d). pub(crate) fn tdb4(spec: &RecSpec, keyed: bool, time_scale: u32) -> Vec { - fixed("tdb4", try_tdb4(spec, keyed, time_scale), || vec![0u8; 124]) + tdb4_layout(spec, keyed, time_scale).finish(|| "tdb4".to_string()) } -fn try_tdb4(spec: &RecSpec, keyed: bool, time_scale: u32) -> R> { - let mut b = vec![0u8; 124]; - put(&mut b, 0, &[0xdb, 0x99, 0x00, spec.dims])?; +fn tdb4_layout(spec: &RecSpec, keyed: bool, time_scale: u32) -> Fixed { + let mut b = Fixed::zeroed(124); + b.put(0, &[0xdb, 0x99, 0x00, spec.dims]); let spatial = match spec.template { KeyTemplate::Plain => 0, KeyTemplate::Spatial(t) => t, @@ -137,36 +137,36 @@ fn try_tdb4(spec: &RecSpec, keyed: bool, time_scale: u32) -> R> { Some(b) => (b & !0x01) | u8::from(!keyed), None => (if spatial == 0 { 0 } else { spatial_byte5(spatial) }) | u8::from(!keyed), }; - put(&mut b, 5, &[byte5])?; - put(&mut b, 7, &[spatial])?; - put(&mut b, 8, &spec.class.to_be_bytes())?; - put(&mut b, 12, &time_scale.to_be_bytes())?; + b.put(5, &[byte5]); + b.put(7, &[spatial]); + b.put(8, &spec.class.to_be_bytes()); + b.put(12, &time_scale.to_be_bytes()); // Every record After Effects writes carries 0.0001 and four 1.0 here; without them a roving // key reads back at time zero instead of its retimed place (read-back s14-re-keyframes). - put(&mut b, 16, &1e-4f64.to_be_bytes())?; + b.put(16, &1e-4f64.to_be_bytes()); for at in [24, 32, 40, 48] { - put(&mut b, at, &1.0f64.to_be_bytes())?; + b.put(at, &1.0f64.to_be_bytes()); } - put(&mut b, 68, &[u8::from(keyed)])?; + b.put(68, &[u8::from(keyed)]); // Set on a static spatial property, cleared once it is keyed (aep-format.md 3.4, byte 79). - put(&mut b, 79, &[u8::from(spatial != 0 && !keyed)])?; - Ok(b) + b.put(79, &[u8::from(spatial != 0 && !keyed)]); + b } /// A key list (`LIST list`: `lhd3` + `ldat`) of `count` records of `size` bytes (aep-format.md /// 3.5): word 0 `0x00d00bee`, word 2 the count, word 3 the blocks of four (at least one), word 4 /// the size, words 5 and 7 the capacity (the count rounded up to four, at least two), word 6 one. pub(crate) fn key_list(size: u32, count: u32, records: Vec) -> Chunk<'static> { - let lhd3 = fixed("lhd3", key_list_header(size, count), || vec![0u8; 52]); + let lhd3 = key_list_header(size, count).finish(|| "lhd3".to_string()); Chunk::list(*b"list", vec![Chunk::data(*b"lhd3", lhd3), Chunk::data(*b"ldat", records)]) } -fn key_list_header(size: u32, count: u32) -> R> { - let mut lhd3 = vec![0u8; 52]; - put_u32(&mut lhd3, 0, 0x00d0_0bee)?; - put_u32(&mut lhd3, 24, 1)?; - put_key_counts(&mut lhd3, count, size)?; - Ok(lhd3) +fn key_list_header(size: u32, count: u32) -> Fixed { + let mut lhd3 = Fixed::zeroed(52); + lhd3.put(0, &0x00d0_0bee_u32.to_be_bytes()); + lhd3.put(24, &1u32.to_be_bytes()); + lhd3.apply(|d| put_key_counts(d, count, size)); + lhd3 } /// Sets the count words of a key list header for `count` keys of `size` bytes. @@ -235,12 +235,14 @@ mod tests { for template in [KeyTemplate::Plain, KeyTemplate::Spatial(1), KeyTemplate::Spatial(3), KeyTemplate::Spatial(u8::MAX)] { for byte5 in [None, Some(0xff)] { let spec = RecSpec { dims, template, class: u32::MAX, limits: None, byte5 }; - assert!(try_tdb4(&spec, false, u32::MAX).is_ok() && try_tdb4(&spec, true, 0).is_ok()); + assert!(tdb4_layout(&spec, false, u32::MAX).error().is_none() && tdb4_layout(&spec, true, 0).error().is_none()); } } } for (size, count) in [(0, 0), (u32::MAX, u32::MAX), (48, 5)] { - assert_eq!(key_list_header(size, count).map(|h| h.len()).ok(), Some(52)); + let h = key_list_header(size, count); + assert!(h.error().is_none()); + assert_eq!(h.finish(String::new).len(), 52); } } diff --git a/crates/aep/src/export/tree.rs b/crates/aep/src/export/tree.rs index 4c2a28b75..f975e1668 100644 --- a/crates/aep/src/export/tree.rs +++ b/crates/aep/src/export/tree.rs @@ -97,27 +97,61 @@ pub(crate) fn put_cstr(d: &mut [u8], at: usize, len: usize, text: &str) -> R<()> Ok(()) } -/// How many fixed layouts did not fit their buffer (see [`fixed`]); the tests keep it at zero. -static FIXED_LAYOUT_ERRORS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); - -/// The result of a builder whose layout is fixed in code (constant sizes and offsets, no input): -/// its writes go through [`put`], which errors when one does not fit. Such an error is a bug in -/// the layout, never something a project can cause, and the tests build every one of these -/// layouts and require no error. The builders keep their infallible signatures because files -/// outside this module call them without a `Result`; if a layout ever broke, `fallback` is used -/// and the error goes to the log. -pub(crate) fn fixed(builder: &'static str, built: R, fallback: impl FnOnce() -> T) -> T { - built.unwrap_or_else(|e| { - FIXED_LAYOUT_ERRORS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - log::error!("aep export: the fixed layout of {builder} does not fit: {e}"); - fallback() - }) +thread_local! { + /// How many fixed layouts on this thread did not fit their buffer (see [`Fixed`]); the tests + /// keep it at zero. + static FIXED_LAYOUT_ERRORS: std::cell::Cell = const { std::cell::Cell::new(0) }; } -/// How many fixed layouts have failed in this process (for the tests). +/// A buffer whose layout is fixed in code (constant size and offsets, no input from a project). +/// Every write goes through [`put`]; a write that does not fit is skipped and its error kept, the +/// others are written. Such an error is a bug in the layout, never something a project can cause: +/// the tests build every one of these layouts and require no error. The builders keep their +/// infallible signatures because files outside this module call them without a `Result`; if a +/// layout ever broke, [`Fixed::finish`] logs it once and returns what fit. +pub(crate) struct Fixed { + bytes: Vec, + error: Option, +} + +impl Fixed { + /// `len` zero bytes. + pub(crate) fn zeroed(len: usize) -> Fixed { + Fixed { bytes: vec![0u8; len], error: None } + } + + /// Writes `bytes` at `at`, or keeps the error (the first one) when they do not fit. + pub(crate) fn put(&mut self, at: usize, bytes: &[u8]) { + self.apply(|d| put(d, at, bytes)); + } + + /// Runs a writer on the buffer, keeping its error (the first one). + pub(crate) fn apply(&mut self, write: impl FnOnce(&mut [u8]) -> R<()>) { + if let Err(e) = write(&mut self.bytes) { + self.error.get_or_insert(e); + } + } + + /// The error of the first write that did not fit (for the tests). + #[cfg(test)] + pub(crate) fn error(&self) -> Option<&AepExportError> { + self.error.as_ref() + } + + /// The bytes; a write that did not fit is logged once, with what `layout` says. + pub(crate) fn finish(self, layout: impl FnOnce() -> String) -> Vec { + if let Some(e) = self.error { + FIXED_LAYOUT_ERRORS.with(|n| n.set(n.get().saturating_add(1))); + log::error!("aep export: the fixed layout of {} does not fit: {e}", layout()); + } + self.bytes + } +} + +/// How many fixed layouts have failed on this thread (for the tests). #[cfg(test)] pub(crate) fn fixed_layout_errors() -> usize { - FIXED_LAYOUT_ERRORS.load(std::sync::atomic::Ordering::Relaxed) + FIXED_LAYOUT_ERRORS.with(std::cell::Cell::get) } /// The entries of a property group by match name (see [`split_group`]). @@ -179,10 +213,23 @@ mod tests { /// text, cameras, lights, shapes, styles) are built and no write went out of range. #[test] fn every_fixed_layout_fits() { + assert_eq!(fixed_layout_errors(), 0); assert!(crate::export::template::Template::load().is_ok()); assert_eq!(fixed_layout_errors(), 0); } + #[test] + fn a_broken_fixed_layout_writes_what_fits_and_is_counted() { + let before = fixed_layout_errors(); + let mut b = Fixed::zeroed(4); + b.put(0, &[1]); + b.put(3, &[2, 2]); + b.put(2, &[3]); + assert!(b.error().is_some()); + assert_eq!(b.finish(|| "a test layout".to_string()), [1, 0, 3, 0]); + assert_eq!(fixed_layout_errors(), before + 1); + } + #[test] fn a_group_splits_into_header_and_entries() { use crate::export::synth::{tdmn, tdsn};