Something went wrong. Try again.
Data management tools for playing BattleTech
Something went wrong. Try again.
27 kB · 750 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751//! The units themselves, as they travel.//!//! [`helm_facet::UnitFacets`] is what a predicate reads, and it is built in//! three places: from a parsed design, from a database row, and - once a//! browser is doing the filtering - from the documents helm publishes.//!//! # Why it is more than one document//!//! A screen that draws a match report wants a picture and a name. A force//! builder wants every column there is. A tournament wants the year, the tech//! base and the tonnage. Published as one file, the first of those pays 382KB//! to answer a question worth 60.//!//! So the index is a spine and a set of chunks. The spine is what identifies a//! design - the columns nothing can be said without - and each chunk is one//! group of columns, in the spine's order, as an array with no keys in it.//!//! # Why the chunks carry no names//!//! Because names are the expensive column. `chassis` and `model` are long//! strings that gzip cannot do much with: 44KB of the spine's 98KB is names//! alone. A chunk that repeated them would cost more in keys than it carries//! in values - art is 23KB as an array and 64KB as a map - and every chunk//! after the first would pay that toll again.//!//! The price is that a chunk is meaningless without the spine, and that a//! chunk from another run of helm lines up row for row and means the wrong//! designs. [`Build`] is what makes that a refusal rather than a wrong answer,//! and one command writes the whole set so there is never half of one.
use crate::Build;use helm_facet::UnitFacets;use serde::{Deserialize, Serialize, de::DeserializeOwned};
/// One group of columns, published as its own file.////// The set is closed and the compiler enforces it: every `match` over a chunk/// is exhaustive, so a group added here fails to build until the writer writes/// it and the reader reads it.#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]#[serde(rename_all = "lowercase")]pub enum Chunk { /// Which picture each design is drawn with. Art, /// What it weighs, what it is worth, and what it costs. Figures, /// Where it comes from: the year, the tech base, the books. Paperwork, /// How it fights: movement, firepower, reach, heat, plate. Combat, /// What it does at each hex, which is the only honest way to say where a /// design's damage is: short, medium and long are per weapon. Curve, /// MegaMek's per-design quirks. Quirks, /// What it carries, as MegaMek lists it. Loadout,}
impl Chunk { /// Every chunk, in the order one command writes them. pub const ALL: [Chunk; 7] = [ Chunk::Art, Chunk::Figures, Chunk::Paperwork, Chunk::Combat, Chunk::Curve, Chunk::Quirks, Chunk::Loadout, ];
/// What it is called, in a filter's `needs` and in the spine's manifest. pub fn name(self) -> &'static str { match self { Chunk::Art => "art", Chunk::Figures => "figures", Chunk::Paperwork => "paperwork", Chunk::Combat => "combat", Chunk::Curve => "curve", Chunk::Quirks => "quirks", Chunk::Loadout => "loadout", } }
/// The file it is published as, beside the spine. pub fn file(self) -> String { format!("{}.json", self.name()) }
/// The chunk of that name, for a caller reading one back. pub fn named(name: &str) -> Option<Chunk> { Chunk::ALL.into_iter().find(|c| c.name() == name) }}
/// One group of columns for every design, in the spine's order.////// `chunk` is written so that a file cannot be mistaken for another chunk of/// the same shape - art and quirks are both one value per design, and reading/// one as the other would be silent.#[derive(Serialize, Deserialize)]pub struct Part<T> { pub build: Build, pub chunk: Chunk, pub rows: Vec<T>,}
/// One group of columns, projected out of a unit and put back onto one.////// Every facet belongs to exactly one chunk. Nothing here enforces that on its/// own; `every_column_survives_the_split` does, and it cannot be written/// without naming all of them.pub trait Columns: Serialize + DeserializeOwned + Sized { /// Which chunk these columns are published in. const CHUNK: Chunk;
/// Read them off a unit. fn take(unit: &UnitFacets, extra: &Extras) -> Self;
/// Put them back onto one. fn put(self, unit: &mut UnitFacets, extra: &mut Extras);}
/// The two columns a screen draws that no predicate reads.////// They travel with the facets rather than beside them because they are per/// design and positional like everything else, and a third parallel structure/// for two fields would be worse than a struct with two fields in it.#[derive(Debug, Default, Clone, PartialEq, Eq)]pub struct Extras { /// Where the design's picture is, relative to the release's art tree. pub sprite: Option<String>, /// Plate as the file declares it. `armor_pct` is what a filter asks /// about; this is what a row prints. pub total_armor: Option<i64>,}
/// The columns nothing can be said without: what this design is.#[derive(Debug, Default, Clone, Serialize, Deserialize)]#[serde(default, deny_unknown_fields)]pub struct Spine { #[serde(skip_serializing_if = "String::is_empty")] pub name: String, #[serde(skip_serializing_if = "String::is_empty")] pub chassis: String, #[serde(skip_serializing_if = "String::is_empty")] pub model: String, #[serde(skip_serializing_if = "Option::is_none")] pub unit_type: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub config: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub mul_id: Option<i64>,}
/// Which picture each design is drawn with.#[derive(Debug, Default, Serialize, Deserialize)]#[serde(transparent)]pub struct Art(pub Option<String>);
impl Columns for Art { const CHUNK: Chunk = Chunk::Art;
fn take(_unit: &UnitFacets, extra: &Extras) -> Self { Art(extra.sprite.clone()) }
fn put(self, _unit: &mut UnitFacets, extra: &mut Extras) { extra.sprite = self.0; }}
/// What it weighs, what it is worth, and what it costs.#[derive(Debug, Default, Serialize, Deserialize)]#[serde(default, deny_unknown_fields)]pub struct Figures { #[serde(rename = "mass", skip_serializing_if = "Option::is_none")] pub tons: Option<f64>, #[serde(rename = "bv", skip_serializing_if = "Option::is_none")] pub battle_value: Option<i64>, #[serde(skip_serializing_if = "Option::is_none")] pub cost: Option<i64>, #[serde(skip_serializing_if = "Option::is_none")] pub weight_class: Option<i64>, #[serde(skip_serializing_if = "Option::is_none")] pub total_armor: Option<i64>, #[serde(skip_serializing_if = "Option::is_none")] pub canon: Option<bool>, #[serde(skip_serializing_if = "Option::is_none")] pub invalid: Option<bool>, #[serde(skip_serializing_if = "Option::is_none")] pub omni: Option<bool>, #[serde(skip_serializing_if = "Option::is_none")] pub clan: Option<bool>,}
impl Columns for Figures { const CHUNK: Chunk = Chunk::Figures;
fn take(unit: &UnitFacets, extra: &Extras) -> Self { Figures { tons: unit.tons, battle_value: unit.battle_value, cost: unit.cost, weight_class: unit.weight_class, total_armor: extra.total_armor, canon: unit.canon, invalid: unit.invalid, omni: unit.omni, clan: unit.clan, } }
fn put(self, unit: &mut UnitFacets, extra: &mut Extras) { unit.tons = self.tons; unit.battle_value = self.battle_value; unit.cost = self.cost; unit.weight_class = self.weight_class; extra.total_armor = self.total_armor; unit.canon = self.canon; unit.invalid = self.invalid; unit.omni = self.omni; unit.clan = self.clan; }}
/// Where a design comes from.#[derive(Debug, Default, Serialize, Deserialize)]#[serde(default, deny_unknown_fields)]pub struct Paperwork { #[serde(skip_serializing_if = "Option::is_none")] pub year: Option<i64>, #[serde(skip_serializing_if = "Option::is_none")] pub tech_base: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub role: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub rules_level: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub source: Option<String>, #[serde(skip_serializing_if = "Vec::is_empty")] pub sources: Vec<String>,}
impl Columns for Paperwork { const CHUNK: Chunk = Chunk::Paperwork;
fn take(unit: &UnitFacets, _extra: &Extras) -> Self { Paperwork { year: unit.year, tech_base: unit.tech_base.clone(), role: unit.role.clone(), rules_level: unit.rules_level.clone(), source: unit.source.clone(), sources: unit.sources.clone(), } }
fn put(self, unit: &mut UnitFacets, _extra: &mut Extras) { unit.year = self.year; unit.tech_base = self.tech_base; unit.role = self.role; unit.rules_level = self.rules_level; unit.source = self.source; unit.sources = self.sources; }}
/// How a design fights.#[derive(Debug, Default, Serialize, Deserialize)]#[serde(default, deny_unknown_fields)]pub struct Combat { #[serde(skip_serializing_if = "Option::is_none")] pub walk_mp: Option<i64>, #[serde(skip_serializing_if = "Option::is_none")] pub run_mp: Option<i64>, #[serde(skip_serializing_if = "Option::is_none")] pub jump_mp: Option<i64>, #[serde(skip_serializing_if = "Option::is_none")] pub firepower: Option<f64>, #[serde(skip_serializing_if = "Option::is_none")] pub max_range: Option<i64>, #[serde(skip_serializing_if = "Option::is_none")] pub heat: Option<i64>, #[serde(skip_serializing_if = "Option::is_none")] pub dissipation: Option<i64>, #[serde(skip_serializing_if = "Option::is_none")] pub heat_efficiency: Option<i64>, #[serde(skip_serializing_if = "Option::is_none")] pub ammo_dependence: Option<i64>, #[serde(skip_serializing_if = "Option::is_none")] pub one_shot: Option<i64>, #[serde(skip_serializing_if = "Option::is_none")] pub armor_pct: Option<i64>, #[serde(skip_serializing_if = "Option::is_none")] pub melee: Option<bool>, #[serde(skip_serializing_if = "Option::is_none")] pub symmetrical: Option<bool>,}
impl Columns for Combat { const CHUNK: Chunk = Chunk::Combat;
fn take(unit: &UnitFacets, _extra: &Extras) -> Self { Combat { walk_mp: unit.walk_mp, run_mp: unit.run_mp, jump_mp: unit.jump_mp, firepower: unit.firepower, max_range: unit.max_range, heat: unit.heat, dissipation: unit.dissipation, heat_efficiency: unit.heat_efficiency, ammo_dependence: unit.ammo_dependence, one_shot: unit.one_shot, armor_pct: unit.armor_pct, melee: unit.melee, symmetrical: unit.symmetrical, } }
fn put(self, unit: &mut UnitFacets, _extra: &mut Extras) { unit.walk_mp = self.walk_mp; unit.run_mp = self.run_mp; unit.jump_mp = self.jump_mp; unit.firepower = self.firepower; unit.max_range = self.max_range; unit.heat = self.heat; unit.dissipation = self.dissipation; unit.heat_efficiency = self.heat_efficiency; unit.ammo_dependence = self.ammo_dependence; unit.one_shot = self.one_shot; unit.armor_pct = self.armor_pct; unit.melee = self.melee; unit.symmetrical = self.symmetrical; }}
/// What each design does at each hex from 1 to `helm_core::REACH`.#[derive(Debug, Default, Serialize, Deserialize)]#[serde(transparent)]pub struct Curve(pub Vec<i64>);
impl Columns for Curve { const CHUNK: Chunk = Chunk::Curve;
fn take(unit: &UnitFacets, _extra: &Extras) -> Self { Curve(unit.curve.clone()) }
fn put(self, unit: &mut UnitFacets, _extra: &mut Extras) { unit.curve = self.0; }}
/// MegaMek's per-design quirks.#[derive(Debug, Default, Serialize, Deserialize)]#[serde(transparent)]pub struct Quirks(pub Vec<String>);
impl Columns for Quirks { const CHUNK: Chunk = Chunk::Quirks;
fn take(unit: &UnitFacets, _extra: &Extras) -> Self { Quirks(unit.quirks.clone()) }
fn put(self, unit: &mut UnitFacets, _extra: &mut Extras) { unit.quirks = self.0; }}
/// What each design carries, as MegaMek lists it.#[derive(Debug, Default, Serialize, Deserialize)]#[serde(transparent)]pub struct Loadout(pub Vec<(String, i64)>);
impl Columns for Loadout { const CHUNK: Chunk = Chunk::Loadout;
fn take(unit: &UnitFacets, _extra: &Extras) -> Self { Loadout(unit.loadout.clone()) }
fn put(self, unit: &mut UnitFacets, _extra: &mut Extras) { unit.loadout = self.0; }}
/// How much of the library is answered, which is not the same as how large it/// is.#[derive(Debug, Default, Clone, Serialize, Deserialize)]pub struct Counts { pub units: usize, /// Designs no producer could score. A screen showing none of them is /// showing a shorter library than it says. pub without_battle_value: usize, /// Designs MegaMek draws with a default silhouette. pub without_sprite: usize,}
/// The spine: which designs there are, and where the rest of them is.#[derive(Serialize, Deserialize)]pub struct Index { pub build: Build, /// The release, again, at the top level. /// /// Where it was before there was a build block, and where headquarters-api /// still reads it: that service refuses an index it cannot find a /// `megamek` in and falls back to drawing silhouettes. Written until it /// reads `build.megamek` instead, and no longer. #[serde(default, skip_serializing_if = "String::is_empty")] pub megamek: String, /// The same, for the helm build. See `megamek` above. #[serde(default, skip_serializing_if = "Option::is_none")] pub helm: Option<String>, /// Relative to the release, and the prefix a sprite path is joined to. /// The bucket holds the art tree at this path under the same version, so /// a consumer needs no second convention to turn one into an address. pub sprite_base: String, pub counts: Counts, /// The files published beside this one, so a reader discovers them rather /// than being told about them by whoever wrote its fetching code. pub chunks: Vec<String>, /// The designs, in the order every chunk repeats. #[serde(with = "spine")] pub units: Vec<(UnitFacets, Extras)>,}
/// Only the spine's own columns cross, which is what makes a chunk worth/// having: the rest of each unit is filled in when its chunk arrives.mod spine { use super::{Extras, Spine, UnitFacets}; use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub fn serialize<S: Serializer>( units: &[(UnitFacets, Extras)], out: S, ) -> Result<S::Ok, S::Error> { let rows: Vec<Spine> = units .iter() .map(|(unit, _)| Spine { name: unit.name.clone(), chassis: unit.chassis.clone(), model: unit.model.clone(), unit_type: unit.unit_type.clone(), config: unit.config.clone(), mul_id: unit.mul_id, }) .collect(); rows.serialize(out) }
pub fn deserialize<'de, D: Deserializer<'de>>( input: D, ) -> Result<Vec<(UnitFacets, Extras)>, D::Error> { Ok(Vec::<Spine>::deserialize(input)? .into_iter() .map(|row| { ( UnitFacets { name: row.name, chassis: row.chassis, model: row.model, unit_type: row.unit_type, config: row.config, mul_id: row.mul_id, ..Default::default() }, Extras::default(), ) }) .collect()) }}
/// Why a chunk could not be put onto a spine.#[derive(Debug, Clone, PartialEq, Eq)]pub enum Mismatch { /// The two files are about different runs of helm. DifferentBuild { index: Box<Build>, chunk: Box<Build>, }, /// The same run, and one of the files is truncated. DifferentLength { index: usize, chunk: usize }, /// A file of the right shape and the wrong contents: art read as quirks. DifferentChunk { wanted: Chunk, got: Chunk },}
impl std::fmt::Display for Mismatch { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Mismatch::DifferentBuild { index, chunk } => { write!(f, "this index is {index} and that chunk is {chunk}") } Mismatch::DifferentLength { index, chunk } => write!( f, "{index} designs are indexed and {chunk} are in the chunk" ), Mismatch::DifferentChunk { wanted, got } => write!( f, "that is the {} chunk, not the {}", got.name(), wanted.name() ), } }}
impl std::error::Error for Mismatch {}
impl Index { /// The units, as the predicate wants them. pub fn facets(&self) -> Vec<&UnitFacets> { self.units.iter().map(|(unit, _)| unit).collect() }
/// What a screen draws that no predicate reads. pub fn extras(&self) -> Vec<&Extras> { self.units.iter().map(|(_, extra)| extra).collect() }
/// The chunk this run published, for one group of columns. pub fn part<C: Columns>(&self) -> Part<C> { Part { build: self.build.clone(), chunk: C::CHUNK, rows: self .units .iter() .map(|(unit, extra)| C::take(unit, extra)) .collect(), } }
/// Put one group of columns onto the designs it belongs to, or say why the /// two files are not about the same run. /// /// A positional join is right or it is nonsense: nothing about the fifth /// row of one file says whether it is the fifth design of the other. So /// this is the only way the two are put together. pub fn join<C: Columns>(&mut self, part: Part<C>) -> Result<(), Mismatch> { if part.chunk != C::CHUNK { return Err(Mismatch::DifferentChunk { wanted: C::CHUNK, got: part.chunk, }); } if !self.build.joins(&part.build) { return Err(Mismatch::DifferentBuild { index: Box::new(self.build.clone()), chunk: Box::new(part.build), }); } if self.units.len() != part.rows.len() { return Err(Mismatch::DifferentLength { index: self.units.len(), chunk: part.rows.len(), }); } for ((unit, extra), columns) in self.units.iter_mut().zip(part.rows) { columns.put(unit, extra); } Ok(()) }}
#[cfg(test)]mod tests { use super::*;
fn build(names: &[&str]) -> Build { Build::new( "0.51.0", None, "megamek-bridge", None, names.iter().copied(), ) }
/// One design with every column set to something distinguishable, written /// out in full. /// /// The literal is the point: it names all of them, so a facet added to /// `UnitFacets` fails to compile here until somebody has decided which /// chunk publishes it. That is the whole guarantee that splitting the /// index does not quietly drop a column. fn a_unit() -> (UnitFacets, Extras) { ( UnitFacets { name: "Atlas AS7-D".into(), chassis: "Atlas".into(), model: "AS7-D".into(), unit_type: Some("Mek".into()), config: Some("Biped".into()), mul_id: Some(87), tech_base: Some("Inner Sphere".into()), role: Some("Juggernaut".into()), source: Some("TR:3039,TR:SW".into()), sources: vec!["TR:3039".into(), "TR:SW".into()], rules_level: Some("Standard".into()), year: Some(2755), weight_class: Some(4), tons: Some(100.0), battle_value: Some(1897), cost: Some(9_000_000), canon: Some(true), invalid: Some(false), omni: Some(false), clan: Some(false), melee: Some(true), symmetrical: Some(false), walk_mp: Some(3), run_mp: Some(5), jump_mp: Some(0), firepower: Some(43.5), max_range: Some(21), heat: Some(28), dissipation: Some(20), heat_efficiency: Some(-8), ammo_dependence: Some(62), one_shot: Some(0), armor_pct: Some(93), curve: vec![25; 24], quirks: vec!["battle_fists".into()], loadout: vec![("Autocannon/20".into(), 1), ("Medium Laser".into(), 4)], }, Extras { sprite: Some("meks/atlas.png".into()), total_armor: Some(307), }, ) }
fn index(units: Vec<(UnitFacets, Extras)>) -> Index { let names: Vec<&str> = units.iter().map(|(u, _)| u.name.as_str()).collect(); Index { build: build(&names), megamek: "0.51.0".into(), helm: None, sprite_base: "data/images/units".into(), counts: Counts::default(), chunks: Chunk::ALL.iter().map(|c| c.file()).collect(), units, } }
/// Split the whole set out and put it all back: what comes out the far /// side is what went in, column for column. #[test] fn every_column_survives_the_split() { let whole = index(vec![a_unit()]); let (art, figures, paperwork, combat, curve, quirks, loadout) = ( whole.part::<Art>(), whole.part::<Figures>(), whole.part::<Paperwork>(), whole.part::<Combat>(), whole.part::<Curve>(), whole.part::<Quirks>(), whole.part::<Loadout>(), );
// Nothing but the spine crosses in the index itself. let text = serde_json::to_string(&whole).expect("write"); let mut read: Index = serde_json::from_str(&text).expect("read"); assert_eq!(read.units[0].0.battle_value, None, "a chunk column crossed"); assert_eq!(read.units[0].0.name, "Atlas AS7-D");
for part in [ serde_json::to_string(&art).unwrap(), serde_json::to_string(&figures).unwrap(), serde_json::to_string(&paperwork).unwrap(), serde_json::to_string(&combat).unwrap(), serde_json::to_string(&curve).unwrap(), serde_json::to_string(&quirks).unwrap(), serde_json::to_string(&loadout).unwrap(), ] { let chunk: serde_json::Value = serde_json::from_str(&part).unwrap(); match Chunk::named(chunk["chunk"].as_str().unwrap()).unwrap() { Chunk::Art => read.join(serde_json::from_str::<Part<Art>>(&part).unwrap()), Chunk::Figures => read.join(serde_json::from_str::<Part<Figures>>(&part).unwrap()), Chunk::Paperwork => { read.join(serde_json::from_str::<Part<Paperwork>>(&part).unwrap()) } Chunk::Combat => read.join(serde_json::from_str::<Part<Combat>>(&part).unwrap()), Chunk::Curve => read.join(serde_json::from_str::<Part<Curve>>(&part).unwrap()), Chunk::Quirks => read.join(serde_json::from_str::<Part<Quirks>>(&part).unwrap()), Chunk::Loadout => read.join(serde_json::from_str::<Part<Loadout>>(&part).unwrap()), } .expect("the same run"); }
assert_eq!(read.units[0], a_unit()); }
/// Two files from different runs line up row for row and mean different /// designs. Refused, because the alternative is a page telling somebody a /// Locust carries a Gauss rifle. #[test] fn a_chunk_from_another_run_is_refused() { let mut whole = index(vec![a_unit()]); let part = Part { build: build(&["Locust LCT-1V"]), chunk: Chunk::Loadout, rows: vec![Loadout(vec![("ISGaussRifle".into(), 1)])], }; let err = whole.join(part).expect_err("a different run"); assert!(matches!(err, Mismatch::DifferentBuild { .. }), "{err}"); assert_eq!(whole.units[0].0.loadout, a_unit().0.loadout); }
/// Nothing about the shape of a chunk says which chunk it is - art and /// quirks are both one value per design, and today they are told apart by /// luck rather than by design. The name written in the file is what makes /// that a refusal instead of a silent misreading. #[test] fn a_chunk_read_as_another_is_refused() { let mut whole = index(vec![a_unit()]); let mislabelled = Part { build: whole.build.clone(), chunk: Chunk::Art, rows: vec![Quirks(vec!["battle_fists".into()])], }; let err = whole .join(mislabelled) .expect_err("that says it is the art"); assert_eq!( err.to_string(), "that is the art chunk, not the quirks", "{err}" ); }
/// Absent is the common case and an empty column is not worth its name: a /// design with no computed record writes three fields, not thirty-one. #[test] fn an_empty_column_is_not_written() { let whole = index(vec![( UnitFacets { name: "Locust LCT-1V".into(), ..Default::default() }, Extras::default(), )]); let text = serde_json::to_string(&whole.part::<Figures>()).unwrap(); assert!(text.ends_with(r#""rows":[{}]}"#), "{text}"); }}