use super::*;
use fallow_output::{CssAnalyticsReport, CssAnalyticsSummary};
const MANY_TOKENS: u32 = 10_000;
fn score(report: &CssAnalyticsReport) -> StylingHealth {
compute_styling_health(report, MANY_TOKENS)
}
fn approx(actual: f64, expected: f64) {
assert!(
(actual - expected).abs() < 0.05,
"expected ~{expected}, got {actual}"
);
}
fn clean_report() -> CssAnalyticsReport {
CssAnalyticsReport {
files: Vec::new(),
summary: CssAnalyticsSummary {
files_analyzed: 1,
total_declarations: 50,
..CssAnalyticsSummary::default()
},
scoped_unused: Vec::new(),
unreferenced_keyframes: Vec::new(),
undefined_keyframes: Vec::new(),
duplicate_declaration_blocks: Vec::new(),
cva_duplicate_variant_blocks: Vec::new(),
cva_variant_token_drifts: Vec::new(),
tailwind_arbitrary_values: Vec::new(),
raw_style_values: Vec::new(),
unused_at_rules: Vec::new(),
unresolved_class_references: Vec::new(),
unreferenced_css_classes: Vec::new(),
unused_font_faces: Vec::new(),
unused_theme_tokens: Vec::new(),
near_duplicate_theme_tokens: Vec::new(),
token_consumers: Vec::new(),
font_size_unit_mix: None,
}
}
#[test]
fn clean_report_scores_100_grade_a() {
let styling = score(&clean_report());
assert_eq!(styling.formula_version, STYLING_HEALTH_FORMULA_VERSION);
approx(styling.score, 100.0);
assert_eq!(styling.grade, "A");
let p = &styling.penalties;
approx(p.duplication, 0.0);
approx(p.dead_surface, 0.0);
approx(p.broken_references, 0.0);
approx(p.token_erosion, 0.0);
approx(p.structural, 0.0);
}
#[test]
fn duplication_penalty_is_down_weighted_and_caps() {
let mut report = clean_report();
report.summary.total_declarations = 100;
report.summary.duplicate_declarations_total = 5;
let styling = score(&report);
approx(styling.penalties.duplication, 4.0);
report.summary.duplicate_declarations_total = 20;
let styling = score(&report);
approx(styling.penalties.duplication, 16.0);
report.summary.duplicate_declarations_total = 30;
let styling = score(&report);
approx(styling.penalties.duplication, DUPLICATION_CAP);
}
#[test]
fn dead_surface_token_death_term_is_per_population() {
let mut report = clean_report();
report.summary.total_declarations = 24; report.summary.unused_theme_tokens = 8;
let styling = compute_styling_health(&report, 10);
approx(styling.penalties.dead_surface, 12.0);
let styling = compute_styling_health(&report, 400);
approx(styling.penalties.dead_surface, 0.3);
report.summary.unused_theme_tokens = 4;
let styling = compute_styling_health(&report, 32);
approx(styling.penalties.dead_surface, 1.9);
report.summary.unused_theme_tokens = 50;
let styling = compute_styling_health(&report, 20);
approx(styling.penalties.dead_surface, TOKEN_DEATH_TERM_CAP);
}
#[test]
fn dead_surface_other_term_scales_by_declaration_share_and_is_size_stable() {
let mut report = clean_report();
report.summary.total_declarations = 50;
report.summary.unreferenced_css_classes = 2;
let styling = compute_styling_health(&report, MANY_TOKENS);
approx(styling.penalties.dead_surface, 6.0);
report.summary.files_analyzed = 32;
let styling = compute_styling_health(&report, MANY_TOKENS);
approx(styling.penalties.dead_surface, 6.0);
report.summary.files_analyzed = 1;
report.summary.unreferenced_css_classes = 4;
report.summary.unused_property_registrations = 3;
report.summary.unused_layers = 2;
report.summary.unused_font_faces = 1;
let styling = compute_styling_health(&report, MANY_TOKENS);
approx(styling.penalties.dead_surface, OTHER_DEAD_TERM_CAP);
}
#[test]
fn dead_surface_combines_terms_and_caps_at_category_ceiling() {
let mut report = clean_report();
report.summary.total_declarations = 50;
report.summary.unused_theme_tokens = 30;
report.summary.unreferenced_css_classes = 20; let styling = compute_styling_health(&report, 30);
approx(styling.penalties.dead_surface, DEAD_SURFACE_CAP);
let mut a = clean_report();
a.summary.total_declarations = 24;
a.summary.unused_theme_tokens = 6;
let mut b = clean_report();
b.summary.total_declarations = 480; b.summary.unused_theme_tokens = 6;
let styling_a = compute_styling_health(&a, 40);
let styling_b = compute_styling_health(&b, 40);
approx(styling_a.penalties.dead_surface, 2.3);
approx(styling_b.penalties.dead_surface, 2.3);
}
#[test]
fn broken_references_penalty_scales_and_caps() {
let mut report = clean_report();
report.summary.unresolved_class_references = 1;
report.summary.keyframes_undefined = 1;
let styling = score(&report);
approx(styling.penalties.broken_references, 6.0);
report.summary.unresolved_class_references = 10;
report.summary.keyframes_undefined = 0;
let styling = score(&report);
approx(styling.penalties.broken_references, BROKEN_REFERENCES_CAP);
}
#[test]
fn token_erosion_penalty_saturates_gently() {
let mut report = clean_report();
report.summary.font_size_units_used = 3;
report.summary.tailwind_arbitrary_values = 1;
let styling = score(&report);
approx(styling.penalties.token_erosion, 2.1);
report.summary.font_size_units_used = 2;
report.summary.tailwind_arbitrary_values = 0;
let styling = score(&report);
approx(styling.penalties.token_erosion, 0.0);
report.summary.tailwind_arbitrary_values = 50;
let styling = score(&report);
approx(styling.penalties.token_erosion, 2.8);
report.summary.font_size_units_used = 10;
report.summary.tailwind_arbitrary_values = 0;
let styling = score(&report);
approx(styling.penalties.token_erosion, 4.0);
report.summary.tailwind_arbitrary_values = 200;
let styling = score(&report);
approx(styling.penalties.token_erosion, TOKEN_EROSION_CAP);
}
#[test]
fn value_sprawl_below_per_axis_baselines_is_zero() {
let mut report = clean_report();
report.summary.unique_box_shadows = 5;
report.summary.unique_border_radii = 4;
report.summary.unique_line_heights = 2;
let styling = score(&report);
approx(styling.penalties.token_erosion, 0.0);
report.summary.unique_border_radii = 7;
report.summary.unique_line_heights = 4;
report.summary.unique_box_shadows = 1;
let styling = score(&report);
approx(styling.penalties.token_erosion, 0.0);
}
#[test]
fn value_sprawl_fires_above_baselines_saturates_and_caps() {
let mut report = clean_report();
report.summary.unique_box_shadows = 16;
report.summary.unique_border_radii = 16;
report.summary.unique_line_heights = 16;
let styling = score(&report);
approx(styling.penalties.token_erosion, 4.0);
let mut single = clean_report();
single.summary.unique_box_shadows = 20;
let styling = score(&single);
approx(styling.penalties.token_erosion, 1.7);
let mut extreme = clean_report();
extreme.summary.unique_box_shadows = 40;
extreme.summary.unique_border_radii = 40;
extreme.summary.unique_line_heights = 40;
let styling = score(&extreme);
approx(styling.penalties.token_erosion, 5.0);
}
#[test]
fn value_sprawl_sums_with_existing_token_erosion_terms_under_category_cap() {
let mut report = clean_report();
report.summary.font_size_units_used = 3;
report.summary.tailwind_arbitrary_values = 50;
report.summary.unique_box_shadows = 16;
report.summary.unique_border_radii = 16;
report.summary.unique_line_heights = 16;
let styling = score(&report);
approx(styling.penalties.token_erosion, 8.8);
report.summary.font_size_units_used = 10;
report.summary.tailwind_arbitrary_values = 200;
let styling = score(&report);
approx(styling.penalties.token_erosion, TOKEN_EROSION_CAP);
}
#[test]
fn structural_penalty_uses_important_density_and_nesting() {
let mut report = clean_report();
report.summary.total_declarations = 100;
report.summary.important_declarations = 15;
report.summary.max_nesting_depth = 4;
let styling = score(&report);
approx(styling.penalties.structural, 10.0);
report.summary.important_declarations = 5; report.summary.max_nesting_depth = 7;
let styling = score(&report);
approx(styling.penalties.structural, 3.0);
}
#[test]
fn penalties_compound_into_the_score() {
let mut report = clean_report();
report.summary.total_declarations = 100;
report.summary.duplicate_declarations_total = 5; report.summary.files_analyzed = 1;
report.summary.unreferenced_css_classes = 1; report.summary.unresolved_class_references = 1; report.summary.font_size_units_used = 3; report.summary.important_declarations = 15; report.summary.max_nesting_depth = 4;
let styling = score(&report);
let p = &styling.penalties;
approx(p.duplication, 4.0);
approx(p.dead_surface, 1.5);
approx(p.broken_references, 3.0);
approx(p.token_erosion, 2.0);
approx(p.structural, 10.0);
approx(styling.score, 79.5);
assert_eq!(styling.grade, "B");
}
#[test]
fn score_floors_at_rubric_minimum_for_pathological_report() {
let mut report = clean_report();
report.summary.total_declarations = 100;
report.summary.duplicate_declarations_total = 100; report.summary.files_analyzed = 1;
report.summary.unused_theme_tokens = 100; report.summary.unreferenced_css_classes = 100; report.summary.unresolved_class_references = 100; report.summary.font_size_units_used = 20; report.summary.tailwind_arbitrary_values = 200; report.summary.important_declarations = 100; report.summary.max_nesting_depth = 20;
let styling = compute_styling_health(&report, 100);
approx(styling.score, 25.0);
assert!(styling.score >= 0.0);
assert_eq!(styling.grade, "F");
}
#[test]
fn clean_report_is_high_confidence() {
let styling = score(&clean_report());
assert_eq!(styling.confidence, StylingHealthConfidence::High);
assert!(styling.confidence_reason.is_none());
}
#[test]
fn sparse_report_is_low_confidence_with_reason() {
let mut report = clean_report();
report.summary.total_declarations = 24;
report.summary.files_analyzed = 2;
let styling = score(&report);
assert_eq!(styling.confidence, StylingHealthConfidence::Low);
let reason = styling
.confidence_reason
.expect("low confidence carries a reason");
assert!(reason.contains("24 declarations"), "reason: {reason}");
assert!(reason.contains("2 stylesheets"), "reason: {reason}");
}
#[test]
fn confidence_reason_is_singular_for_one_of_each() {
let mut report = clean_report();
report.summary.total_declarations = 1;
report.summary.files_analyzed = 1;
let reason = score(&report)
.confidence_reason
.expect("low confidence carries a reason");
assert!(reason.contains("1 declaration across"), "reason: {reason}");
assert!(reason.contains("1 stylesheet"), "reason: {reason}");
assert!(!reason.contains("declarations"), "reason: {reason}");
assert!(!reason.contains("stylesheets"), "reason: {reason}");
}
#[test]
fn confidence_boundary_is_at_min_declarations() {
let mut report = clean_report();
report.summary.total_declarations = 49;
assert_eq!(score(&report).confidence, StylingHealthConfidence::Low);
report.summary.total_declarations = 50;
assert_eq!(score(&report).confidence, StylingHealthConfidence::High);
}
#[test]
fn confidence_does_not_change_score_or_grade() {
let mut sparse = clean_report();
sparse.summary.total_declarations = 24;
let mut solid = clean_report();
solid.summary.total_declarations = 500;
let a = score(&sparse);
let b = score(&solid);
assert_eq!(a.confidence, StylingHealthConfidence::Low);
assert_eq!(b.confidence, StylingHealthConfidence::High);
approx(a.score, 100.0);
approx(b.score, 100.0);
assert_eq!(a.grade, "A");
assert_eq!(b.grade, "A");
}
#[test]
fn apply_styling_penalties_clamps_below_zero() {
let penalties = StylingHealthPenalties {
duplication: 40.0,
dead_surface: 40.0,
broken_references: 30.0,
token_erosion: 20.0,
structural: 20.0,
};
let score = apply_styling_penalties(&penalties);
#[expect(
clippy::float_cmp,
reason = "the clamp lower bound is an exact 0.0 literal, so an exact compare is correct here"
)]
{
assert_eq!(score, 0.0);
}
}
fn report_with_duplicates(duplicate_total: u32, summary_total: u32) -> CssAnalyticsReport {
let mut report = clean_report();
report.summary.total_declarations = summary_total;
report.summary.duplicate_declarations_total = duplicate_total;
report
}
#[test]
fn duplication_uses_non_atomic_denominator_not_total() {
let report = report_with_duplicates(8, 450);
let inputs = StylingScoringInputs {
theme_tokens_defined: MANY_TOKENS,
non_atomic_declarations: 50,
non_atomic_important_declarations: 0,
non_atomic_max_nesting_depth: 0,
atomic_declarations: 400,
};
let styling = compute_styling_health_with_inputs(&report, &inputs);
approx(styling.penalties.duplication, 12.8);
}
#[test]
fn structural_penalty_reads_non_atomic_inputs_only() {
let report = clean_report();
let inputs = StylingScoringInputs {
theme_tokens_defined: MANY_TOKENS,
non_atomic_declarations: 100,
non_atomic_important_declarations: 12,
non_atomic_max_nesting_depth: 6,
atomic_declarations: 500,
};
let styling = compute_styling_health_with_inputs(&report, &inputs);
approx(styling.penalties.structural, 9.0);
}
#[test]
fn predominantly_atomic_is_low_confidence_with_atomic_reason() {
let report = clean_report();
let inputs = StylingScoringInputs {
theme_tokens_defined: MANY_TOKENS,
non_atomic_declarations: 60,
non_atomic_important_declarations: 0,
non_atomic_max_nesting_depth: 0,
atomic_declarations: 240,
};
let styling = compute_styling_health_with_inputs(&report, &inputs);
assert_eq!(styling.confidence, StylingHealthConfidence::Low);
let reason = styling.confidence_reason.expect("atomic caveat reason");
assert!(
reason.contains("compile-time-atomic") && reason.contains("token hygiene"),
"atomic reason names non-assessability: {reason:?}"
);
assert!(
!reason.contains("graded from only"),
"atomic reason wins over the sample-size reason: {reason:?}"
);
}
#[test]
fn mostly_non_atomic_with_a_little_atomic_stays_high_confidence() {
let report = clean_report();
let inputs = StylingScoringInputs {
theme_tokens_defined: MANY_TOKENS,
non_atomic_declarations: 100,
non_atomic_important_declarations: 0,
non_atomic_max_nesting_depth: 0,
atomic_declarations: 20,
};
let styling = compute_styling_health_with_inputs(&report, &inputs);
assert_eq!(styling.confidence, StylingHealthConfidence::High);
assert!(styling.confidence_reason.is_none());
}
#[test]
fn no_atomic_split_matches_back_compat_entry() {
let mut report = clean_report();
report.summary.important_declarations = 15;
report.summary.total_declarations = 100;
report.summary.max_nesting_depth = 4;
let via_wrapper = compute_styling_health(&report, MANY_TOKENS);
let via_inputs = compute_styling_health_with_inputs(
&report,
&StylingScoringInputs::from_report(&report, MANY_TOKENS),
);
approx(via_wrapper.score, via_inputs.score);
assert_eq!(via_wrapper.grade, via_inputs.grade);
assert_eq!(via_wrapper.confidence, via_inputs.confidence);
}