Toolbars for every capability, VectorCraft icons, coming soon for planned ones (#20)

* feat(engine): capability catalogue — every toolbar entry, ready or coming soon, and docs/features.md

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* feat: 98 toolbar icons drawn with VectorCraft from recipes (cargo xtask icons), attributed

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* feat(ui): toolbar of tabs and panels from the catalogue, VectorCraft icons recoloured per theme, coming-soon entries greyed with their milestone; ui.toolbar

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* refactor: ready entries are their command; one tooltip; tabs and panels as catalogue runs; icon cache per theme and size; ui.toolbar read-only

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* docs: roadmap notes the toolbar and icons

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* feat(ui): deck entries run deck.write with their format, ready when the writer is in FORMATS; file and edit entries; compact and collapsed panels instead of scrolling

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* docs: features page after the Nastran writer (#24) made its deck entry ready

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* docs: features page after the LS-DYNA writer (#25) made its deck entry ready

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* feat: solve.static, solve.frequency and solve.probe on the toolbar (Solve and Results tabs)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-13 17:49:31 -04:00
1 parent 40f98f0af9
commit a292212b67
320 files changed
+2990 -88

No files matched your search

+450
View File
@@ -0,0 +1,450 @@
//! The capability catalogue: everything ModelCraft does or will do, in one table.
//!
//! Each [`Capability`] is a toolbar entry: its tab and panel, its label and one-line tip, and
//! either the command of the same id it runs ([`Status::Ready`]) or the milestone that brings it
//! ([`Status::Planned`], shown greyed as "Coming soon"). Planned entries are not commands: a
//! command always runs. The icon of an entry is `assets/icons/<id>.svg`.
//!
//! Every registered command is claimed here, as an entry's command or as one of its related
//! commands ([`Capability::more`], the entry's drop-down); the tests check it. A new command
//! claims its place by turning its planned entry into a ready one (or by adding an entry with
//! an icon), see `docs/features.md`.
use std::fmt::Write;
use serde::{Deserialize, Serialize};
/// The toolbar tabs, in the order of an analyst's workflow.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Tab {
#[default]
Geometry,
Mesh,
Properties,
Assembly,
Steps,
Loads,
Interactions,
Solve,
Results,
Tools,
}
impl Tab {
pub const ALL: [Tab; 10] =
[Tab::Geometry, Tab::Mesh, Tab::Properties, Tab::Assembly, Tab::Steps, Tab::Loads, Tab::Interactions, Tab::Solve, Tab::Results, Tab::Tools];
pub fn label(self) -> &'static str {
match self {
Tab::Geometry => "Geometry",
Tab::Mesh => "Mesh",
Tab::Properties => "Properties",
Tab::Assembly => "Assembly",
Tab::Steps => "Steps",
Tab::Loads => "Loads & BCs",
Tab::Interactions => "Interactions",
Tab::Solve => "Solve",
Tab::Results => "Results",
Tab::Tools => "Tools",
}
}
}
/// Whether an entry runs today.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Status {
/// Runs the command whose id is the entry's.
Ready,
/// Coming in this roadmap milestone (`M2`…).
Planned { milestone: &'static str },
/// Runs `deck.write` with this `format` once `modelcraft_decks::FORMATS` has a writer for it;
/// until then coming in `milestone`.
Deck { format: &'static str, milestone: &'static str },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Capability {
/// Our own dotted id; also the icon's file name.
pub id: &'static str,
pub label: &'static str,
pub tab: Tab,
pub panel: &'static str,
/// One line: what it does.
pub tip: &'static str,
pub status: Status,
/// Related commands in the entry's drop-down (edit, delete…).
pub more: &'static [&'static str],
}
impl Capability {
/// The command it runs; `None` while it is coming soon.
pub fn command(&self) -> Option<&'static str> {
match self.status {
Status::Ready => Some(self.id),
Status::Planned { .. } => None,
Status::Deck { format, .. } => modelcraft_decks::format(format).map(|_| crate::cmd::deck::WRITE.id),
}
}
/// The parameters its command starts with (an object; empty for most entries).
pub fn preset(&self) -> serde_json::Value {
match self.status {
Status::Deck { format, .. } => serde_json::json!({ "format": format }),
Status::Ready | Status::Planned { .. } => serde_json::json!({}),
}
}
/// The milestone that brings it, while it is coming soon.
pub fn milestone(&self) -> Option<&'static str> {
match self.status {
Status::Planned { milestone } => Some(milestone),
Status::Deck { milestone, .. } if self.command().is_none() => Some(milestone),
Status::Ready | Status::Deck { .. } => None,
}
}
/// The hover text: label, tip and command, or when it comes.
pub fn tooltip(&self) -> String {
match (self.command(), self.milestone()) {
(Some(command), _) => format!("{}\n{}\n{command}", self.label, self.tip),
(None, m) => format!("{} — coming soon ({})\n{}", self.label, m.unwrap_or("later"), self.tip),
}
}
}
const fn ready(id: &'static str, label: &'static str, tab: Tab, panel: &'static str, tip: &'static str) -> Capability {
Capability { id, label, tab, panel, tip, status: Status::Ready, more: &[] }
}
const fn ready_more(
id: &'static str,
label: &'static str,
tab: Tab,
panel: &'static str,
tip: &'static str,
more: &'static [&'static str],
) -> Capability {
Capability { more, ..ready(id, label, tab, panel, tip) }
}
const fn soon(id: &'static str, label: &'static str, tab: Tab, panel: &'static str, milestone: &'static str, tip: &'static str) -> Capability {
Capability { id, label, tab, panel, tip, status: Status::Planned { milestone }, more: &[] }
}
const fn deck(id: &'static str, label: &'static str, format: &'static str, tip: &'static str) -> Capability {
Capability { id, label, tab: Tab::Solve, panel: "Decks", tip, status: Status::Deck { format, milestone: "M3" }, more: &[] }
}
use Tab::*;
/// Every capability in toolbar order: grouped by tab (in [`Tab::ALL`] order), then by panel.
pub static CATALOGUE: &[Capability] = &[
// ---- Geometry ----
ready("geometry.open", "Open Design", Geometry, "Import", "Open a SolidCraft design: its bodies with persistent face names"),
ready("geometry.import.mesh", "Tagged Mesh", Geometry, "Import", "Read a face-tagged surface mesh (3MF)"),
ready("geometry.bodies", "Bodies", Geometry, "Inspect", "List the open design's bodies, volumes and named faces"),
ready("geometry.tessellate", "Tessellate", Geometry, "Inspect", "Tessellate a body into a face-tagged boundary surface"),
ready(
"mesh.surface.check",
"Check Surface",
Geometry,
"Inspect",
"Weld, closed, manifold, orientation and self-intersection checks; feature edges",
),
soon("geometry.partition", "Partition", Geometry, "Prepare", "M5", "Split bodies and faces as SolidCraft features"),
soon("geometry.datum", "Datum Plane", Geometry, "Prepare", "M5", "Construction planes for partitions"),
soon("geometry.midsurface", "Midsurface", Geometry, "Prepare", "M8", "Extract midsurfaces of thin parts for shell meshes"),
// ---- Mesh ----
soon("mesh.size.global", "Global Size", Mesh, "Size", "M2", "One element size for the whole part"),
soon("mesh.size.seed", "Seed Edges", Mesh, "Size", "M2", "Element counts, sizes and bias per edge and face"),
soon("mesh.size.adapt", "Curvature", Mesh, "Size", "M2", "Refine by curvature and proximity"),
soon("mesh.tet.generate", "Tet Mesh", Mesh, "Tet", "M2", "Delaunay tetrahedral mesh of a body, boundary recovered and refined"),
soon("mesh.order.quadratic", "Quadratic", Mesh, "Tet", "M2", "Second-order elements with mid-side nodes on the geometry"),
ready("mesh.delaunay.points", "Delaunay Points", Mesh, "Tet", "Delaunay tetrahedralization of a point list (debugging aid)"),
soon("mesh.quad.faces", "Quad Faces", Mesh, "Structured Hex", "M5", "All-quad meshes of faces on shared edge nodes"),
soon("mesh.hex.mapped", "Mapped Hex", Mesh, "Structured Hex", "M5", "Mapped hex mesh of six-sided regions"),
soon("mesh.hex.sweep", "Sweep Hex", Mesh, "Structured Hex", "M5", "Hex mesh swept along extrusions and revolutions"),
soon("mesh.hex.decompose", "Decompose", Mesh, "Structured Hex", "M5", "Find sweepable and mappable regions and propose the splits"),
soon("mesh.transition", "Transition", Mesh, "Structured Hex", "M5", "Conforming tet–hex interfaces with pyramids"),
soon("mesh.hex.generate", "All-Hex", Mesh, "Automatic Hex", "M6", "Octree dual all-hex mesh of any part"),
soon("mesh.hex.dominant", "Hex-Dominant", Mesh, "Automatic Hex", "M6", "Hex-dominant mesh by recombining tets"),
soon("mesh.hex.polycube", "Polycube", Mesh, "Automatic Hex", "M6", "Polycube hex mesh of blocky parts"),
soon("mesh.smooth", "Smooth", Mesh, "Improve", "M6", "Smooth node positions, keeping every element valid"),
soon("mesh.untangle", "Untangle", Mesh, "Improve", "M6", "Repair inverted elements"),
soon("mesh.layers", "Layers", Mesh, "Improve", "M6", "Boundary padding and layers through thin walls"),
ready_more("mesh.node.add", "Node", Mesh, "Edit", "Add a node", &["mesh.node.remove"]),
ready_more("mesh.element.add", "Element", Mesh, "Edit", "Add an element on existing nodes", &["mesh.element.remove"]),
soon("mesh.node.merge", "Merge Nodes", Mesh, "Edit", "M6", "Merge coincident nodes"),
soon("mesh.region.remesh", "Remesh Region", Mesh, "Edit", "M6", "Remesh a region in place; split and collapse elements"),
soon("mesh.import.orphan", "Orphan Mesh", Mesh, "Edit", "M6", "Import a mesh without geometry"),
ready("mesh.quality", "Quality", Mesh, "Verify", "Quality metrics and the certified validity check, failing elements listed"),
soon("mesh.verify", "Verify Set", Mesh, "Verify", "M2", "Quality and validity per set"),
ready_more("mesh.tag", "Tag Geometry", Mesh, "Verify", "Tag elements, faces, edges and nodes with the geometry they mesh", &["mesh.tag.clear"]),
// ---- Properties ----
ready_more(
"model.material.create",
"Material",
Properties,
"Materials",
"Elastic (isotropic, orthotropic) material with density",
&["model.material.edit", "model.material.delete"],
),
soon("model.material.plasticity", "Plasticity", Properties, "Materials", "M8", "Plastic hardening data"),
soon("model.material.hyperelastic", "Hyperelastic", Properties, "Materials", "M8", "Hyperelastic material models"),
soon("model.material.damage", "Damage", Properties, "Materials", "M8", "Damage initiation and evolution"),
soon("model.material.thermal", "Thermal", Properties, "Materials", "M8", "Conductivity, specific heat and expansion"),
soon(
"model.material.eos",
"Equation of State",
Properties,
"Materials",
"M11",
"Polynomial, ideal gas, Mie–Grüneisen, JWL and tabulated equations of state",
),
ready_more(
"model.section.create",
"Section",
Properties,
"Sections",
"Solid, shell, beam or rod section assigned to a set, with its element formulation",
&["model.section.edit", "model.section.delete"],
),
soon("model.section.composite", "Composite", Properties, "Sections", "M8", "Composite layups and continuum shells"),
soon("model.orientation", "Orientation", Properties, "Sections", "M8", "Material orientations"),
ready_more(
"model.set.create",
"Set",
Properties,
"Sets",
"A set of nodes, elements and faces, or of named geometry that survives remeshing",
&["model.set.edit", "model.set.delete", "model.set.resolve"],
),
ready("model.units", "Units", Properties, "Model", "The model's unit system (never converted)"),
// ---- Assembly ----
soon("assembly.instance", "Instances", Assembly, "Instances", "M7", "Part instances placed from SolidCraft occurrences"),
// ---- Steps ----
ready_more(
"model.step.create",
"Step",
Steps,
"Steps",
"A linear static or frequency step with its output",
&["model.step.edit", "model.step.delete"],
),
ready("model.step.activate", "Activate", Steps, "Steps", "Choose which loads and boundary conditions act in a step"),
ready("model.activity", "Step Table", Steps, "Steps", "The step by load and boundary condition table"),
soon("model.amplitude", "Amplitude", Steps, "Procedures", "M7", "Time and frequency amplitudes"),
soon("model.step.nonlinear", "Nonlinear", Steps, "Procedures", "M7", "Nonlinear static steps"),
soon("model.step.explicit", "Explicit", Steps, "Procedures", "M8", "Explicit dynamic steps"),
soon("model.step.heat", "Heat Transfer", Steps, "Procedures", "M8", "Heat transfer steps"),
// ---- Loads & BCs ----
ready_more(
"model.boundary_condition.create",
"Boundary Condition",
Loads,
"Boundary Conditions",
"Fixed, displacement or symmetry condition on a set or reference point",
&["model.boundary_condition.edit", "model.boundary_condition.delete"],
),
ready_more(
"model.load.create",
"Load",
Loads,
"Loads",
"Force, moment, pressure, traction, gravity or body force",
&["model.load.edit", "model.load.delete"],
),
soon("flow.domain", "Fluid Domain", Loads, "Flow", "M12", "The fluid grid voxelised from the face-tagged surface"),
soon("flow.boundary", "Inlet & Outlet", Loads, "Flow", "M12", "Velocity inlets, pressure outlets and walls"),
// ---- Interactions ----
ready_more(
"model.reference_point.create",
"Reference Point",
Interactions,
"Constraints",
"A reference point for couplings, loads and boundary conditions",
&["model.reference_point.edit", "model.reference_point.delete"],
),
ready_more(
"model.coupling.create",
"Coupling",
Interactions,
"Constraints",
"Kinematic or distributing coupling of a set to a reference point",
&["model.coupling.edit", "model.coupling.delete"],
),
soon("interaction.tie", "Tie", Interactions, "Constraints", "M7", "Tie non-matching meshes together"),
soon("interaction.contact.general", "General Contact", Interactions, "Contact", "M7", "Contact between everything that touches, with friction"),
soon("interaction.contact.pair", "Contact Pair", Interactions, "Contact", "M7", "Contact between two surfaces"),
soon("interaction.contact.find", "Find Contacts", Interactions, "Contact", "M7", "Detect contact pairs automatically"),
soon("interaction.rigid_wall", "Rigid Wall", Interactions, "Contact", "M10", "Rigid walls for explicit dynamics"),
soon("interaction.bolt", "Bolt Load", Interactions, "Connectors", "M7", "Bolt pretension"),
soon("interaction.spring", "Spring", Interactions, "Connectors", "M7", "Springs and dashpots"),
soon("interaction.mass", "Point Mass", Interactions, "Connectors", "M7", "Point masses and inertia"),
soon("interaction.connector", "Connector", Interactions, "Connectors", "M7", "Connector elements between points"),
soon("interaction.fsi", "FSI Interface", Interactions, "Coupled", "M13", "The fluid–structure interface"),
// ---- Solve ----
ready("model.check", "Check Model", Solve, "Check", "Name every missing or bad definition before writing or solving"),
deck("deck.write.abaqus", "Abaqus Input", "abaqus", "Write an Abaqus input file (.inp)"),
deck("deck.write.nastran", "Nastran Deck", "nastran", "Write Nastran bulk data (SOL 101, 103)"),
deck("deck.write.lsdyna", "LS-DYNA Deck", "lsdyna", "Write an LS-DYNA keyword file"),
soon("deck.read", "Read Deck", Solve, "Decks", "M9", "Read a deck, keeping keywords ModelCraft does not know"),
ready("solve.static", "Static", Solve, "Implicit", "Linear static solve of a step with ModelCraft's own solver"),
ready("solve.frequency", "Frequency", Solve, "Implicit", "Natural frequencies and mode shapes of a frequency step"),
soon("solve.explicit", "Explicit", Solve, "Explicit", "M10", "Explicit dynamics: central differences, contact, erosion"),
soon("solve.blast", "Blast & Impact", Solve, "Explicit", "M14", "EOS-driven fluids with fluid–structure contact (ALE or SPH)"),
soon("solve.flow", "Lattice Boltzmann", Solve, "Fluids", "M12", "Lattice Boltzmann flow on the CPU or the GPU"),
soon("solve.fsi", "Fluid–Structure", Solve, "Fluids", "M13", "Coupled fluid and structure solve"),
// ---- Results ----
soon("results.deformed", "Deformed", Results, "Display", "M4", "The deformed shape"),
soon("results.contour", "Contours", Results, "Display", "M4", "Contour plots of stress, strain and displacement"),
soon("results.modes", "Mode Shapes", Results, "Display", "M4", "Mode shapes of a frequency step"),
soon("results.animate", "Animate", Results, "Display", "M9", "Animate modes and time histories"),
ready("solve.probe", "Probe", Results, "Query", "Displacement, reaction and stress at nodes, or a mode shape"),
soon("results.reactions", "Reactions", Results, "Query", "M4", "Reaction force and moment sums"),
soon("results.free_body", "Free Body", Results, "Query", "M9", "Free-body forces across a cut"),
soon("results.xy", "XY Plot", Results, "Plots", "M9", "Plot a quantity against time or another"),
soon("results.path", "Path Plot", Results, "Plots", "M9", "Plot a quantity along a path"),
soon("results.cut", "Cut", Results, "Plots", "M9", "Section cuts through the model"),
soon("results.display_group", "Display Group", Results, "Plots", "M9", "Show and hide parts of the model"),
soon("results.streamlines", "Streamlines", Results, "Plots", "M12", "Streamlines and flow fields"),
soon("results.read", "Read Results", Results, "Files", "M9", "Read results in public formats"),
soon("results.convergence", "Convergence", Results, "Files", "M9", "Mesh convergence studies"),
// ---- Tools ----
ready("model.info", "Model Info", Tools, "Model", "Node and element counts and the model summary"),
ready("file.open", "Open Model", Tools, "File", "Open a .modelcraft model"),
ready("file.save", "Save", Tools, "File", "Save the model to its .modelcraft file"),
ready("file.save_as", "Save As", Tools, "File", "Save the model to a new .modelcraft file"),
ready_more(
"file.recover",
"Recover",
Tools,
"File",
"Open a model autosaved by an app that crashed or closed with unsaved changes",
&["file.recovery.list", "file.recovery.discard"],
),
ready("edit.undo", "Undo", Tools, "History", "Undo the last command that changed the model"),
ready("edit.redo", "Redo", Tools, "History", "Redo the command undone last"),
];
/// The catalogue entry with this id.
pub fn find(id: &str) -> Option<&'static Capability> {
CATALOGUE.iter().find(|c| c.id == id)
}
/// The entries of a tab (one run of the catalogue).
pub fn tab_entries(tab: Tab) -> &'static [Capability] {
let start = CATALOGUE.iter().position(|c| c.tab == tab).unwrap_or(CATALOGUE.len());
let rest = CATALOGUE.get(start..).unwrap_or_default();
rest.get(..rest.iter().take_while(|c| c.tab == tab).count()).unwrap_or_default()
}
/// The panels of a tab in order, each the run of its entries (never empty).
pub fn panels(tab: Tab) -> impl Iterator<Item = &'static [Capability]> {
tab_entries(tab).chunk_by(|a, b| a.panel == b.panel)
}
/// `docs/features.md`: every capability by tab and panel, ready or coming soon.
pub fn features_page() -> String {
let ready = CATALOGUE.iter().filter(|c| c.command().is_some()).count();
let mut md = String::from(
"# Features\n\n\
Generated from the capability catalogue (`crates/engine/src/catalogue.rs`) by\n\
`cargo run -p modelcraft-cli -- features > docs/features.md`; a test fails when it is out of date.\n\
Every entry is a toolbar button. **Ready** entries run their command; **coming soon** entries are\n\
shown greyed with the roadmap milestone that brings them (ROADMAP.md).\n\n",
);
let _ = writeln!(md, "{ready} ready, {} coming soon.", CATALOGUE.len() - ready);
for tab in Tab::ALL {
let _ = write!(md, "\n## {}\n\n| Panel | Capability | Status | Command |\n|---|---|---|---|\n", tab.label());
for c in tab_entries(tab) {
let status = c.milestone().map_or_else(|| "ready".to_string(), |m| format!("coming soon ({m})"));
let preset = c.preset();
let main =
c.command().map(|id| if preset.as_object().is_some_and(|o| !o.is_empty()) { format!("`{id} {preset}`") } else { format!("`{id}`") });
let commands: Vec<String> = main.into_iter().chain(c.more.iter().map(|id| format!("`{id}`"))).collect();
let _ = writeln!(md, "| {} | {} — {} | {status} | {} |", c.panel, c.label, c.tip, commands.join(", "));
}
}
md
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{commands, find_command};
#[test]
fn every_command_is_claimed_and_every_ready_entry_runs_a_command() {
let mut claimed: Vec<&str> = Vec::new();
for c in CATALOGUE {
if let Some(command) = c.command() {
assert!(find_command(command).is_some(), "{}: `{command}` is not a registered command", c.id);
claimed.push(command);
} else {
assert!(c.more.is_empty(), "{}: a planned entry has no commands", c.id);
}
for m in c.more {
assert!(find_command(m).is_some(), "{}: `{m}` is not a registered command", c.id);
claimed.push(m);
}
}
for spec in commands() {
let n = claimed.iter().filter(|c| **c == spec.id).count();
assert!(n >= 1, "command `{}` has no catalogue entry (a ready entry or an entry's `more`)", spec.id);
}
}
/// Deck entries follow the writers: one entry per format, ready exactly when its writer is in
/// `modelcraft_decks::FORMATS`, running `deck.write` with its format.
#[test]
fn deck_entries_follow_the_writers() {
let decks: Vec<(&Capability, &str)> =
CATALOGUE.iter().filter_map(|c| if let Status::Deck { format, .. } = c.status { Some((c, format)) } else { None }).collect();
for f in modelcraft_decks::FORMATS {
assert!(decks.iter().any(|(_, name)| *name == f.name), "deck format `{}` has no catalogue entry", f.name);
}
for (c, format) in decks {
let written = modelcraft_decks::format(format).is_some();
assert_eq!(c.command(), written.then_some("deck.write"), "{}", c.id);
assert_eq!(c.milestone().is_none(), written, "{}", c.id);
assert_eq!(c.preset(), serde_json::json!({"format": format}));
}
assert_eq!(find("deck.write.abaqus").and_then(Capability::command), Some("deck.write"));
}
#[test]
fn entries_are_well_formed() {
let mut ids: Vec<&str> = CATALOGUE.iter().map(|c| c.id).collect();
for c in CATALOGUE {
assert!(c.id.split('.').count() >= 2 && c.id.chars().all(|ch| ch.is_ascii_lowercase() || ch == '.' || ch == '_'), "{}", c.id);
assert!(!c.label.is_empty() && !c.panel.is_empty() && !c.tip.is_empty(), "{}", c.id);
if let Some(m) = c.milestone() {
assert!(m.strip_prefix('M').is_some_and(|n| n.parse::<u32>().is_ok_and(|n| (1..=14).contains(&n))), "{}: {m}", c.id);
}
}
ids.sort_unstable();
let n = ids.len();
ids.dedup();
assert_eq!(ids.len(), n, "catalogue ids are unique");
// Tabs are runs in Tab::ALL order, and each panel is one run within its tab.
let total: usize = Tab::ALL.iter().map(|t| tab_entries(*t).len()).sum();
assert_eq!(total, CATALOGUE.len(), "every tab's entries are together, in Tab::ALL order");
let order: Vec<Tab> =
CATALOGUE.iter().map(|c| c.tab).collect::<Vec<_>>().chunk_by(|a, b| a == b).filter_map(|r| r.first().copied()).collect();
assert_eq!(order, Tab::ALL);
for tab in Tab::ALL {
let names: Vec<&str> = panels(tab).filter_map(|p| p.first().map(|c| c.panel)).collect();
assert!(!names.is_empty(), "{tab:?} has no entries");
assert!(names.iter().enumerate().all(|(i, n)| !names[..i].contains(n)), "{tab:?}: a panel's entries are together: {names:?}");
}
}
#[test]
fn features_page_is_current() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/features.md");
let committed = std::fs::read_to_string(&path).unwrap_or_default().replace("\r\n", "\n");
assert!(committed == features_page(), "docs/features.md is out of date: run `cargo run -p modelcraft-cli -- features > docs/features.md`");
}
}
+1
View File
@@ -13,6 +13,7 @@
#![forbid(unsafe_code)]
pub mod autosave;
pub mod catalogue;
pub mod cmd;
pub mod history;
pub mod sample;