mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Shipped code no longer unwraps, expects or panics: every one of the 124 unwrap()/expect()/panic!/unreachable! sites outside tests now handles the failure (Result/Option, ?, let-else, defaults) and reports errors to the user. - Lints: workspace clippy denies unwrap_used, expect_used, panic, todo, unimplemented and unreachable; clippy.toml allows them in tests, and integration tests, examples and testkit opt out explicitly. - Safety net (vectorcraft_engine::guard): a panic in a top-level command rolls the document back and returns EngineError::Internal; a panicking tool is reset; the MCP server answers with an internal error and keeps serving; the UI frame and each control request are guarded. - APIs that hid failures now return them: Rendered::to_png/to_jpeg/to_webp return Result (JPEG/WebP errors used to write empty files), FontDb::face returns Option, Region::top returns Option. - Fuzzing: engine/tests/import_fuzz.rs feeds garbage, hostile and mutated SVG and PDF through import → render → export (30k cases locally, no panics). - Docs: AGENTS.md/CLAUDE.md rule, docs/development.md "Robustness" section, README pointer and a PR template checklist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
121 lines
5.3 KiB
Rust
121 lines
5.3 KiB
Rust
//! SVG export/import properties: never panics, always re-imports, and plain art survives the round
|
||
//! trip visually (render comparison with a perceptual tolerance).
|
||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||
|
||
use proptest::prelude::*;
|
||
use vectorcraft_color::{Color, Paint};
|
||
use vectorcraft_doc::Appearance;
|
||
use vectorcraft_geom::{PathData, Rect, shapes};
|
||
use vectorcraft_svg::{ExportOptions, Styling, export, import};
|
||
use vectorcraft_testkit::fixtures::{self, DocBuilder, art_nodes};
|
||
use vectorcraft_testkit::invariants::check_document;
|
||
use vectorcraft_testkit::raster::{assert_similar, render_artboard};
|
||
use vectorcraft_testkit::strategies::{arb_closed_shape, arb_ops, arb_path_data};
|
||
|
||
fn all_options() -> Vec<ExportOptions> {
|
||
let mut v = vec![];
|
||
for styling in [Styling::PresentationAttributes, Styling::InlineStyle, Styling::InternalCss] {
|
||
for (minify, decimals, object_ids, responsive) in [(false, 3, true, false), (true, 1, false, true), (false, 7, true, true)] {
|
||
v.push(ExportOptions { artboard: Some(0), styling, decimals, object_ids, minify, responsive, outline_text: false });
|
||
}
|
||
}
|
||
v.push(ExportOptions { artboard: None, ..Default::default() });
|
||
v
|
||
}
|
||
|
||
fn arb_color() -> impl Strategy<Value = Color> {
|
||
(0u8..=255, 0u8..=255, 0u8..=255).prop_map(|(r, g, b)| Color::rgb8(r, g, b))
|
||
}
|
||
|
||
proptest! {
|
||
#![proptest_config(ProptestConfig { cases: 48, failure_persistence: None, ..ProptestConfig::default() })]
|
||
|
||
/// Documents from random editing export (all option combos) and re-import to valid documents.
|
||
#[test]
|
||
fn random_documents_export_and_reimport(ops in arb_ops(5..40)) {
|
||
let mut s = fixtures::session();
|
||
for op in &ops {
|
||
let _ = op.apply(&mut s);
|
||
}
|
||
let d = s.doc().unwrap().doc.clone();
|
||
for o in all_options() {
|
||
let svg = export(&d, &o);
|
||
let back = import(&svg).map_err(|e| TestCaseError::fail(format!("{o:?}: {e}\n{svg}")))?;
|
||
check_document(&back).map_err(TestCaseError::fail)?;
|
||
}
|
||
}
|
||
|
||
/// Solid-filled shapes look the same after export → import.
|
||
#[test]
|
||
fn plain_art_survives_visually(shapes_ in prop::collection::vec((arb_closed_shape(), arb_color(), 0.2f32..1.0), 1..6)) {
|
||
let mut b = DocBuilder::new(200.0, 200.0);
|
||
for (p, c, o) in &shapes_ {
|
||
b.path(p.clone(), Appearance::basic(Paint::solid(*c), Paint::None, 0.0), |n| n.opacity = *o);
|
||
}
|
||
let d = b.build();
|
||
let back = import(&export(&d, &ExportOptions::default())).unwrap();
|
||
assert_similar(&render_artboard(&d), &render_artboard(&back), 6.0, 0.002);
|
||
}
|
||
|
||
/// Path geometry survives within the export precision.
|
||
#[test]
|
||
fn path_bounds_survive(p in arb_path_data(), decimals in 2u8..6) {
|
||
let mut b = DocBuilder::new(200.0, 200.0);
|
||
b.path(p.clone(), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0), |_| {});
|
||
let d = b.build();
|
||
let back = import(&export(&d, &ExportOptions { decimals, ..Default::default() })).unwrap();
|
||
let nodes = art_nodes(&back);
|
||
let paths: Vec<&PathData> = nodes.iter().filter_map(|n| n.path_data()).collect();
|
||
prop_assert!(!paths.is_empty(), "no path back");
|
||
// Multi-subpath paths may come back split into several objects; compare the union.
|
||
let bb = paths.iter().filter_map(|q| q.bounds()).reduce(|x, y| x.union(y)).unwrap();
|
||
let a = p.bounds().unwrap();
|
||
let eps = 2.0 * 10f64.powi(-(decimals as i32));
|
||
prop_assert!((a.x0 - bb.x0).abs() <= eps && (a.y0 - bb.y0).abs() <= eps && (a.x1 - bb.x1).abs() <= eps && (a.y1 - bb.y1).abs() <= eps, "{a:?} vs {bb:?}");
|
||
}
|
||
|
||
/// Importing arbitrary text never panics.
|
||
#[test]
|
||
fn import_garbage_never_panics(s in ".{0,200}") {
|
||
let _ = import(&s);
|
||
let _ = import(&format!("<svg xmlns=\"http://www.w3.org/2000/svg\">{s}</svg>"));
|
||
}
|
||
|
||
/// Importing random path data never panics and gives a valid document.
|
||
#[test]
|
||
fn import_random_path_data(d in "[MLCQZHVAmlcqzhva0-9 .,eE+-]{0,80}") {
|
||
let svg = format!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"><path d=\"{d}\"/></svg>");
|
||
if let Ok(doc) = import(&svg) {
|
||
check_document(&doc).map_err(TestCaseError::fail)?;
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn rich_fixture_exports_with_all_options() {
|
||
let s = fixtures::rich_session();
|
||
let d = s.doc().unwrap().doc.clone();
|
||
for o in all_options() {
|
||
let svg = export(&d, &o);
|
||
let back = import(&svg).unwrap_or_else(|e| panic!("{o:?}: {e}"));
|
||
check_document(&back).unwrap();
|
||
if o.minify {
|
||
assert!(!svg.contains("\n "), "minified output is indented");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn rect_survives_visually_with_stroke() {
|
||
let mut b = DocBuilder::new(100.0, 100.0);
|
||
b.path(
|
||
shapes::rectangle(Rect::new(20.0, 20.0, 80.0, 70.0)),
|
||
Appearance::basic(Paint::solid(Color::rgb8(30, 120, 200)), Paint::solid(Color::BLACK), 3.0),
|
||
|_| {},
|
||
);
|
||
let d = b.build();
|
||
let back = import(&export(&d, &ExportOptions::default())).unwrap();
|
||
assert_similar(&render_artboard(&d), &render_artboard(&back), 4.0, 0.001);
|
||
}
|