test(flatten): add golden, filter, invariant, and differential regression tests

11 tests in tests/flatten_regression.rs: projection goldens (line/circle/polyline3d/polyline2d/text/solid), None cases, locked-layer/selection/space collection filters, plan/apply invariants, legacy-loop differential. Adds 2 ring-depth tests in src/scene/mod.rs pinning update_entities' collapsed block-sentinel delta (legacy 2 vs batch 1, final-state equality).
This commit is contained in:
Karim Jerbi committed 2026-09-28 20:29:39 +01:00
1 parent 91900c4e73
commit f4dfead9c0
2 files changed
+352

No files matched your search

+131
View File
@@ -13686,3 +13686,134 @@ mod layout_cache_tests {
assert!(after.iter().any(|(n, _, _)| n == "1:50"));
}
}
#[cfg(test)]
mod update_entities_batch_tests {
use super::Scene;
use codec::entities::{Block, BlockEnd, Line};
use codec::types::Vector3;
use codec::{EntityType, Handle};
/// Fresh scene holding two block sentinels (Block + BlockEnd) and one plain
/// line; returns their handles in that order.
fn scene_with_sentinels_and_line() -> (Scene, Handle, Handle, Handle) {
let mut scene = Scene::new();
let bh = scene
.document
.add_entity(EntityType::Block(Block::new("BATCH_TEST", Vector3::ZERO)))
.unwrap();
let be = scene
.document
.add_entity(EntityType::BlockEnd(BlockEnd::new()))
.unwrap();
let mut line = Line::new();
line.start = Vector3::new(0.0, 0.0, 1.0);
line.end = Vector3::new(3.0, 4.0, 2.0);
let lh = scene.document.add_entity(EntityType::Line(line)).unwrap();
(scene, bh, be, lh)
}
/// Stored entity as-is — the update shape the command arms actually pass.
fn snapshot(scene: &Scene, handle: Handle) -> EntityType {
scene.document.get_entity(handle).unwrap().clone()
}
/// The same line with an edited Z, still carrying its original handle.
fn edited_line(scene: &Scene, handle: Handle) -> EntityType {
let mut entity = snapshot(scene, handle);
if let EntityType::Line(line) = &mut entity {
line.start.z = 5.0;
line.end.z = 6.0;
}
entity
}
fn delta_count(scene: &Scene) -> usize {
scene.geometry_deltas.borrow().len()
}
#[test]
fn update_entities_batch_of_block_sentinels_collapses_to_one_full_delta() {
// Batched path: one coarse publish for the whole set.
let (mut batched, bh, be, _) = scene_with_sentinels_and_line();
let before = delta_count(&batched);
let updates = vec![snapshot(&batched, bh), snapshot(&batched, be)];
let moved = batched.update_entities(updates);
assert_eq!(moved, 2);
{
let ring = batched.geometry_deltas.borrow();
assert_eq!(
ring.len() - before,
1,
"batched sentinels must publish exactly one geometry delta"
);
assert!(
ring.back().unwrap().full,
"the batched delta must be full (bump_geometry)"
);
}
// Legacy path: each sentinel bumps on its own.
let (mut legacy, bh, be, _) = scene_with_sentinels_and_line();
let before = delta_count(&legacy);
let u1 = snapshot(&legacy, bh);
let u2 = snapshot(&legacy, be);
assert!(legacy.update_entity(u1));
assert!(legacy.update_entity(u2));
assert_eq!(
delta_count(&legacy) - before,
2,
"legacy per-entity updates must publish one delta each"
);
// Same final entity state either way.
for h in [bh, be] {
assert_eq!(
format!("{:?}", batched.document.get_entity(h)),
format!("{:?}", legacy.document.get_entity(h)),
"entity {h:?} differs between batched and legacy updates"
);
}
}
#[test]
fn update_entities_mixed_batch_matches_legacy_states_with_one_coarse_delta() {
// Batched: the sentinel's coarse (full) delta subsumes the line's
// would-be per-entity delta, and both slots are still written.
let (mut batched, bh, _, lh) = scene_with_sentinels_and_line();
let before = delta_count(&batched);
let updates = vec![snapshot(&batched, bh), edited_line(&batched, lh)];
let moved = batched.update_entities(updates);
assert_eq!(moved, 2);
{
let ring = batched.geometry_deltas.borrow();
assert_eq!(
ring.len() - before,
1,
"mixed batch with a sentinel must publish one coarse delta"
);
assert!(ring.back().unwrap().full, "coarse delta must be full");
}
// Legacy: full delta for the sentinel + per-entity delta for the line.
let (mut legacy, bh, _, lh) = scene_with_sentinels_and_line();
let before = delta_count(&legacy);
let u1 = snapshot(&legacy, bh);
let u2 = edited_line(&legacy, lh);
assert!(legacy.update_entity(u1));
assert!(legacy.update_entity(u2));
assert_eq!(
delta_count(&legacy) - before,
2,
"legacy publishes one delta per update"
);
for h in [bh, lh] {
assert_eq!(
format!("{:?}", batched.document.get_entity(h)),
format!("{:?}", legacy.document.get_entity(h)),
"entity {h:?} differs between batched and legacy updates"
);
}
}
}
+221
View File
@@ -0,0 +1,221 @@
//! FLATTEN regression: golden projections, collection filters, plan/apply
//! invariants, and a legacy-vs-batch differential.
use codec::entities::{Line, Polyline2D, Polyline3D, Solid, Vertex2D, Vertex3DPolyline};
use codec::types::Vector3;
use codec::{EntityType, Handle};
use OpenCADStudio::modules::draw::modify::flatten::{
apply_flatten_updates, collect_flatten_handles, flatten_entity_z, plan_flatten,
};
use OpenCADStudio::scene::Scene;
fn line_with_z(z0: f64, z1: f64) -> EntityType {
let mut line = Line::new();
line.start = Vector3::new(0.0, 0.0, z0);
line.end = Vector3::new(3.0, 4.0, z1);
line.thickness = 2.0;
EntityType::Line(line)
}
#[test]
fn golden_line_projects_and_resets_thickness_normal() {
let flat = flatten_entity_z(&line_with_z(3.0, 5.0)).expect("moves");
let EntityType::Line(l) = flat else { panic!("variant") };
assert_eq!((l.start.z, l.end.z), (0.0, 0.0));
assert_eq!((l.start.x, l.start.y), (0.0, 0.0));
assert_eq!((l.end.x, l.end.y), (3.0, 4.0));
assert_eq!(l.thickness, 0.0);
assert_eq!(l.normal, Vector3::UNIT_Z);
}
#[test]
fn golden_tilted_circle_becomes_ellipse() {
let mut c = codec::entities::Circle::from_center_radius(Vector3::new(1.0, 2.0, 4.0), 5.0);
c.normal = Vector3::new(0.0, 0.6, 0.8);
let flat = flatten_entity_z(&EntityType::Circle(c)).expect("moves");
assert!(matches!(flat, EntityType::Ellipse(_)), "tilted circle → ellipse");
}
#[test]
fn golden_polyline3d_vertices_and_elevation_zeroed() {
let mut pl = Polyline3D::new();
pl.elevation = 5.0;
pl.vertices.push(Vertex3DPolyline::new(Vector3::new(1.0, 2.0, 7.5)));
let flat = flatten_entity_z(&EntityType::Polyline3D(pl)).expect("moves");
let EntityType::Polyline3D(pl) = flat else { panic!("variant") };
assert_eq!(pl.elevation, 0.0);
assert!(pl.vertices.iter().all(|v| v.position.z == 0.0 && v.position.x == 1.0));
}
#[test]
fn golden_polyline2d_elevation_zeroed() {
let mut pl = Polyline2D::new();
pl.elevation = 9.0;
pl.vertices.push(Vertex2D::new(Vector3::new(3.0, 4.0, 0.0)));
let flat = flatten_entity_z(&EntityType::Polyline2D(pl)).expect("moves");
let EntityType::Polyline2D(pl) = flat else { panic!("variant") };
assert_eq!(pl.elevation, 0.0);
assert_eq!(pl.vertices[0].location.z, 0.0);
}
#[test]
fn golden_text_insertion_and_normal_zeroed() {
let mut t = codec::entities::Text::new();
t.insertion_point = Vector3::new(1.0, 2.0, 3.0);
t.thickness = 1.0;
let flat = flatten_entity_z(&EntityType::Text(t)).expect("moves");
let EntityType::Text(t) = flat else { panic!("variant") };
assert_eq!(t.insertion_point.z, 0.0);
assert_eq!(t.thickness, 0.0);
assert_eq!(t.normal, Vector3::UNIT_Z);
}
#[test]
fn golden_solid_corners_zeroed() {
let s = Solid::new(
Vector3::new(0.0, 0.0, 1.0),
Vector3::new(1.0, 0.0, 2.0),
Vector3::new(1.0, 1.0, 3.0),
Vector3::new(0.0, 1.0, 4.0),
);
let flat = flatten_entity_z(&EntityType::Solid(s)).expect("moves");
let EntityType::Solid(s) = flat else { panic!("variant") };
assert_eq!(
[s.first_corner.z, s.second_corner.z, s.third_corner.z, s.fourth_corner.z],
[0.0; 4]
);
}
#[test]
fn already_flat_and_unsupported_return_none() {
let mut flat_line = Line::new(); // zero thickness + z-normal by default
flat_line.start = Vector3::new(0.0, 0.0, 0.0);
flat_line.end = Vector3::new(3.0, 4.0, 0.0);
assert!(flatten_entity_z(&EntityType::Line(flat_line)).is_none());
let ray = codec::entities::Ray::new(Vector3::new(0.0, 0.0, 5.0), Vector3::new(0.0, 0.0, 1.0));
assert!(flatten_entity_z(&EntityType::Ray(ray)).is_none());
}
fn scene_with_three_lines() -> (Scene, [Handle; 3]) {
let mut scene = Scene::new();
let a = scene.document.add_entity(line_with_z(1.0, 2.0)).unwrap();
let b = scene.document.add_entity(line_with_z(1.0, 2.0)).unwrap();
let c = scene.document.add_entity(line_with_z(1.0, 2.0)).unwrap();
(scene, [a, b, c])
}
#[test]
fn collect_excludes_locked_layer_and_respects_selection() {
let (mut scene, [a, b, c]) = scene_with_three_lines();
// Lock b's layer.
let mut layer = codec::tables::Layer::new("LOCKED");
layer.flags.locked = true;
scene.document.layers.add(layer).unwrap();
scene.document.get_entity_mut(b).unwrap().common_mut().layer = "LOCKED".into();
// Empty selection → all active/unlocked candidates (b excluded).
let candidates = collect_flatten_handles(&scene);
assert!(candidates.contains(&a) && candidates.contains(&c));
assert!(!candidates.contains(&b));
// Non-empty selection → only the selected handles (minus locked).
scene.selected.insert(a);
scene.selected.insert(b);
let candidates = collect_flatten_handles(&scene);
assert_eq!(candidates, vec![a]);
}
#[test]
fn collect_excludes_entities_outside_active_space() {
let (mut scene, [a, b, c]) = scene_with_three_lines();
// Enter block editor scoping on block b: entities whose owner block
// differs from the active space must be excluded.
scene.block_edit_block = Some(b);
scene.document.get_entity_mut(a).unwrap().common_mut().owner_handle = c; // wrong owner
scene.document.get_entity_mut(c).unwrap().common_mut().owner_handle = b; // active block
let candidates = collect_flatten_handles(&scene);
assert!(!candidates.contains(&a), "wrong-owner entity must be excluded");
assert!(candidates.contains(&c), "owner of the active block must be included");
}
#[test]
fn plan_moves_only_movable_candidates() {
let (mut scene, [a, b, c]) = scene_with_three_lines();
let ray = scene
.document
.add_entity(EntityType::Ray(codec::entities::Ray::new(
Vector3::new(0.0, 0.0, 5.0),
Vector3::new(0.0, 0.0, 1.0),
)))
.unwrap();
let handles = collect_flatten_handles(&scene);
assert_eq!(handles.len(), 4);
let updates = plan_flatten(&scene, &handles);
assert_eq!(updates.len(), 3, "ray must not be planned");
assert!(updates.iter().all(|e| !matches!(e, EntityType::Ray(_))));
let moved = apply_flatten_updates(&mut scene, updates);
assert_eq!(moved, 3);
for h in [a, b, c] {
let EntityType::Line(l) = scene.document.get_entity(h).unwrap() else {
panic!("variant")
};
assert_eq!((l.start.z, l.end.z), (0.0, 0.0));
}
// Ray untouched.
assert!(matches!(scene.document.get_entity(ray).unwrap(), EntityType::Ray(_)));
}
#[test]
fn differential_legacy_loop_vs_apply_flatten_updates() {
let build = || {
let mut scene = Scene::new();
let mut h = Vec::new();
for i in 0..50 {
let line = line_with_z(1.0 + i as f64, 2.0);
h.push(scene.document.add_entity(line).unwrap());
if i % 10 == 0 {
h.push(
scene
.document
.add_entity(EntityType::Ray(codec::entities::Ray::new(
Vector3::new(0.0, 0.0, 5.0),
Vector3::new(0.0, 0.0, 1.0),
)))
.unwrap(),
);
}
if i % 7 == 0 {
h.push(scene.document.add_entity(line_with_z(0.0, 0.0)).unwrap());
}
}
let handles = collect_flatten_handles(&scene);
let updates = plan_flatten(&scene, &handles);
(scene, handles, updates)
};
// Reference: the legacy per-entity loop (what the command arm did).
let (mut legacy, handles, updates) = build();
let mut moved_legacy = 0usize;
for entity in updates {
if legacy.update_entity(entity) {
moved_legacy += 1;
}
}
// Candidate: the helper under test.
let (mut batched, handles2, updates2) = build();
let moved_batched = apply_flatten_updates(&mut batched, updates2);
assert_eq!(moved_legacy, moved_batched);
assert_eq!(handles, handles2);
for h in handles {
assert_eq!(
format!("{:?}", legacy.document.get_entity(h)),
format!("{:?}", batched.document.get_entity(h)),
"entity {h:?} differs between legacy loop and apply_flatten_updates"
);
}
}