mod common;
use ifc_lite_core::IfcType;
use ifc_lite_processing::default_color_for_type;
const NEUTRAL_GRAY: [f32; 4] = [0.8, 0.8, 0.8, 1.0];
fn wasm_default(t: IfcType) -> [f32; 4] {
match t {
IfcType::IfcWall | IfcType::IfcWallStandardCase => [0.85, 0.85, 0.85, 1.0],
IfcType::IfcSlab => [0.7, 0.7, 0.7, 1.0],
IfcType::IfcRoof => [0.6, 0.5, 0.4, 1.0],
IfcType::IfcColumn | IfcType::IfcBeam | IfcType::IfcMember => [0.6, 0.65, 0.7, 1.0],
IfcType::IfcWindow => [0.6, 0.8, 1.0, 0.4],
IfcType::IfcDoor => [0.6, 0.45, 0.3, 1.0],
IfcType::IfcStair => [0.75, 0.75, 0.75, 1.0],
IfcType::IfcRailing => [0.4, 0.4, 0.45, 1.0],
IfcType::IfcPlate | IfcType::IfcCovering => [0.8, 0.8, 0.8, 1.0],
IfcType::IfcCurtainWall => [0.5, 0.7, 0.9, 0.5],
IfcType::IfcFurnishingElement => [0.7, 0.55, 0.4, 1.0],
IfcType::IfcSpace => [0.2, 0.85, 1.0, 0.3],
IfcType::IfcOpeningElement => [1.0, 0.42, 0.29, 0.4],
IfcType::IfcSite => [0.4, 0.8, 0.3, 1.0],
_ => NEUTRAL_GRAY,
}
}
fn processing_default(t: IfcType) -> [f32; 4] {
match t {
IfcType::IfcWall | IfcType::IfcWallStandardCase => [0.85, 0.85, 0.85, 1.0],
IfcType::IfcSlab => [0.7, 0.7, 0.7, 1.0],
IfcType::IfcRoof => [0.6, 0.5, 0.4, 1.0],
IfcType::IfcColumn | IfcType::IfcBeam | IfcType::IfcMember => [0.6, 0.65, 0.7, 1.0],
IfcType::IfcWindow => [0.6, 0.8, 1.0, 0.4],
IfcType::IfcDoor => [0.6, 0.45, 0.3, 1.0],
IfcType::IfcStair | IfcType::IfcStairFlight => [0.75, 0.75, 0.75, 1.0],
IfcType::IfcRailing => [0.4, 0.4, 0.45, 1.0],
IfcType::IfcPlate | IfcType::IfcCovering => [0.8, 0.8, 0.8, 1.0],
IfcType::IfcFurnishingElement => [0.5, 0.35, 0.2, 1.0],
IfcType::IfcSpace => [0.2, 0.85, 1.0, 0.3],
IfcType::IfcOpeningElement => [1.0, 0.42, 0.29, 0.4],
IfcType::IfcSite => [0.4, 0.8, 0.3, 1.0],
IfcType::IfcBuildingElementProxy => [0.6, 0.6, 0.6, 1.0],
_ => NEUTRAL_GRAY,
}
}
const MAPPED_TYPES: &[IfcType] = &[
IfcType::IfcWall,
IfcType::IfcWallStandardCase,
IfcType::IfcSlab,
IfcType::IfcRoof,
IfcType::IfcColumn,
IfcType::IfcBeam,
IfcType::IfcMember,
IfcType::IfcWindow,
IfcType::IfcDoor,
IfcType::IfcStair,
IfcType::IfcStairFlight,
IfcType::IfcRailing,
IfcType::IfcPlate,
IfcType::IfcCovering,
IfcType::IfcCurtainWall,
IfcType::IfcFurnishingElement,
IfcType::IfcSpace,
IfcType::IfcOpeningElement,
IfcType::IfcSite,
IfcType::IfcBuildingElementProxy,
];
const CONTESTED: &[IfcType] = &[
IfcType::IfcStairFlight,
IfcType::IfcCurtainWall,
IfcType::IfcFurnishingElement,
IfcType::IfcBuildingElementProxy,
];
fn is_contested(t: IfcType) -> bool {
CONTESTED.contains(&t)
}
#[test]
fn union_agrees_with_both_tables_on_uncontested_types() {
for &t in MAPPED_TYPES {
if is_contested(t) {
continue;
}
let canonical = default_color_for_type(t).to_array();
assert_eq!(
canonical,
wasm_default(t),
"{t:?}: canonical must match the wasm table on uncontested types"
);
assert_eq!(
canonical,
processing_default(t),
"{t:?}: canonical must match the processing table on uncontested types"
);
}
}
#[test]
fn union_picks_the_documented_winner_for_contested_types() {
let cases = [
(IfcType::IfcStairFlight, [0.75, 0.75, 0.75, 1.0], false), (IfcType::IfcCurtainWall, [0.5, 0.7, 0.9, 0.5], true), (IfcType::IfcFurnishingElement, [0.7, 0.55, 0.4, 1.0], true), (IfcType::IfcBuildingElementProxy, [0.6, 0.6, 0.6, 1.0], false), ];
for (t, expected, from_wasm) in cases {
let canonical = default_color_for_type(t).to_array();
assert_eq!(canonical, expected, "{t:?}: unexpected canonical value");
let winner = if from_wasm {
wasm_default(t)
} else {
processing_default(t)
};
assert_eq!(canonical, winner, "{t:?}: canonical must equal the chosen source table");
}
assert_ne!(
default_color_for_type(IfcType::IfcFurnishingElement).to_array(),
processing_default(IfcType::IfcFurnishingElement),
"furnishing must change away from processing's [0.5,0.35,0.2,1]"
);
}
#[test]
fn exactly_four_types_change_per_table() {
let wasm_deltas: Vec<IfcType> = MAPPED_TYPES
.iter()
.copied()
.filter(|&t| default_color_for_type(t).to_array() != wasm_default(t))
.collect();
let processing_deltas: Vec<IfcType> = MAPPED_TYPES
.iter()
.copied()
.filter(|&t| default_color_for_type(t).to_array() != processing_default(t))
.collect();
assert_eq!(
wasm_deltas,
vec![IfcType::IfcStairFlight, IfcType::IfcBuildingElementProxy],
"unexpected changes relative to the wasm table"
);
assert_eq!(
processing_deltas,
vec![IfcType::IfcCurtainWall, IfcType::IfcFurnishingElement],
"unexpected changes relative to the processing table"
);
}
fn repo_root() -> Option<std::path::PathBuf> {
let mut dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf();
loop {
if dir.join("rust").is_dir() && dir.join("apps").is_dir() {
return Some(dir);
}
if !dir.pop() {
return None;
}
}
}
const SCANNED_FLOOR: usize = 440;
fn collect_rs_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => panic!(
"styling parity: {} does not exist. Refusing to treat a missing \
directory as one holding no .rs files - these guards conclude from \
an absence, so a scan root that is not there would read as clean.",
dir.display()
),
Err(err) => panic!(
"styling parity: {} could not be read ({err}). Refusing to treat an \
unreadable directory as one holding no .rs files.",
dir.display()
),
};
for entry in entries {
let entry = entry.unwrap_or_else(|err| {
panic!(
"styling parity: an entry of {} could not be read ({err}). \
Refusing to walk past a file these guards could not classify.",
dir.display()
)
});
let path = entry.path();
if path.is_dir() {
let skip = matches!(
path.file_name().and_then(|n| n.to_str()),
Some("target" | "node_modules" | ".git" | "dist" | "build")
);
if !skip {
collect_rs_files(&path, out);
}
} else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
out.push(path);
}
}
}
fn read_walked(path: &std::path::Path) -> String {
std::fs::read_to_string(path).unwrap_or_else(|err| {
panic!(
"styling parity: {} could not be read ({err}). Refusing to skip a \
file this guard could not examine - these guards prove an absence, \
and an unread file is not an absence.",
path.display()
)
})
}
fn declares_default_color_table(src: &str) -> bool {
src.lines().any(|line| {
let line = line.trim_start();
line.starts_with("fn get_default_color")
|| line.starts_with("pub fn get_default_color")
|| line.starts_with("pub(crate) fn get_default_color")
})
}
fn declares_surface_style_extractor(src: &str) -> bool {
src.lines().any(|line| {
let line = line.trim_start();
["fn ", "pub fn ", "pub(crate) fn "].iter().any(|p| {
line.starts_with(&format!("{p}extract_color_from_rendering"))
|| line.starts_with(&format!("{p}extract_color_rgb"))
})
})
}
#[test]
fn the_detectors_still_fire_on_a_known_violation() {
assert!(
declares_default_color_table(" pub fn get_default_color(t: IfcType) -> Color {\n"),
"the default-color-table detector stopped matching a declaration it must catch"
);
assert!(
declares_default_color_table("fn get_default_color_for_type(t: IfcType) -> Color {\n"),
"the default-color-table detector must match the wasm-side name too"
);
assert!(
declares_surface_style_extractor(
" fn extract_color_from_rendering(id: u32) -> Color {\n"
),
"the surface-style detector stopped matching a declaration it must catch"
);
assert!(
declares_surface_style_extractor("pub(crate) fn extract_color_rgb(id: u32) -> Color {\n"),
"the surface-style detector stopped matching `extract_color_rgb`"
);
assert!(
!declares_default_color_table(
"// the historical copies were named `fn get_default_color`\n"
),
"the default-color-table detector must not fire on prose"
);
assert!(
!declares_surface_style_extractor(" let c = extract_color_rgb(id, decoder)?;\n"),
"the surface-style detector must not fire on a call site"
);
}
#[test]
fn no_duplicate_default_color_tables() {
let Some(root) = repo_root() else {
common::refuse_to_skip_in_ci("styling parity guard");
eprintln!("repo root not found (packaged context) — skipping guard");
return;
};
let allow = |rel: &str| rel.ends_with("rust/processing/tests/styling_parity.rs");
let mut files = Vec::new();
collect_rs_files(&root.join("rust"), &mut files);
collect_rs_files(&root.join("apps"), &mut files);
assert!(
files.len() >= SCANNED_FLOOR,
"styling parity walked rust/ and apps/ and reached only {} .rs file(s); \
the floor is {SCANNED_FLOOR}. Refusing a vacuous pass: this guard \
concludes that no second default-color table exists, and a scan that \
examined this little has established no such thing.",
files.len()
);
let mut offenders = Vec::new();
for path in files {
let rel = path
.strip_prefix(&root)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
if allow(&rel) {
continue;
}
if declares_default_color_table(&read_walked(&path)) {
offenders.push(rel);
}
}
assert!(
offenders.is_empty(),
"found per-consumer default-color table(s) outside the canonical \
`processing::style` — use `default_color_for_type` instead (issue #913): {offenders:?}"
);
}
#[test]
fn no_duplicate_surface_style_color_extraction() {
let Some(root) = repo_root() else {
common::refuse_to_skip_in_ci("styling parity guard");
eprintln!("repo root not found (packaged context) — skipping guard");
return;
};
let allow = |rel: &str| {
rel.ends_with("rust/processing/tests/styling_parity.rs")
|| rel.starts_with("rust/geometry/examples/")
};
let mut files = Vec::new();
collect_rs_files(&root.join("rust"), &mut files);
collect_rs_files(&root.join("apps"), &mut files);
assert!(
files.len() >= SCANNED_FLOOR,
"styling parity walked rust/ and apps/ and reached only {} .rs file(s); \
the floor is {SCANNED_FLOOR}. Refusing a vacuous pass: this guard \
concludes that no second surface-style colour extractor exists, and a \
scan that examined this little has established no such thing.",
files.len()
);
let scanned = files.len();
let mut offenders = Vec::new();
let mut allowed_hits = 0usize;
for path in files {
let rel = path
.strip_prefix(&root)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
let declares = declares_surface_style_extractor(&read_walked(&path));
if allow(&rel) {
if declares {
allowed_hits += 1;
}
continue;
}
if declares {
offenders.push(rel);
}
}
assert!(
allowed_hits >= 1,
"the surface-style detector matched nothing at all across {scanned} walked \
file(s), not even the exempt `rust/geometry/examples/` copy it is \
allowed to find. An absence guard that cannot find a declaration it \
KNOWS is there has stopped detecting, so its empty offender list means \
nothing. If that example was deliberately removed, retire this control \
in the same commit."
);
assert!(
offenders.is_empty(),
"surface-style colour extraction must live only in \
`ifc_lite_processing::style::extract_surface_style_colors`; found a per-pipeline \
copy in: {offenders:?}"
);
}