diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 92849b5e9..7c3f0861e 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -95,6 +95,12 @@ jobs: with: key: build-test + # Ubuntu 26.04 split the backward links (America/Godthab, US/Pacific, ...) + # out of tzdata; FsTzdbProvider reads /usr/share/zoneinfo directly. + - name: Install tzdata-legacy + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y tzdata-legacy + - name: Cargo build --all-features run: cargo build --quiet --all-features - name: Test --all-features @@ -116,6 +122,9 @@ jobs: - uses: Swatinem/rust-cache@v2 with: key: features-test + # Backward links, as in build-test. + - name: Install tzdata-legacy + run: sudo apt-get update && sudo apt-get install -y tzdata-legacy - name: Install cargo-all-features uses: baptiste0928/cargo-install@v3.5.0 with: diff --git a/crates/zoneinfo/tests/test_datagen.rs b/crates/zoneinfo/tests/test_datagen.rs index 249c0434c..d80d50eb0 100644 --- a/crates/zoneinfo/tests/test_datagen.rs +++ b/crates/zoneinfo/tests/test_datagen.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; use std::{format, fs::read_to_string, path::Path, string::String, vec::Vec}; -use zoneinfo_rs::{ZoneInfoCompiler, ZoneInfoData}; +use zoneinfo_rs::{compiler::CompiledTransitions, ZoneInfoCompiler, ZoneInfoData}; #[derive(Debug, Serialize, Deserialize)] struct TzifTestData { @@ -27,70 +27,55 @@ fn read_test_data(test_data_dir: &Path, identifier: &str) -> TzifTestData { serde_json::from_str(&read_to_string(path).unwrap()).unwrap() } -fn run_assertions(identifier: &str, zoneinfo_data: ZoneInfoData, test_data: TzifTestData) { - let mut compiler = ZoneInfoCompiler::new(zoneinfo_data); - let computed_zoneinfo = compiler.build_zone(identifier); - - assert_eq!( - computed_zoneinfo.initial_record.offset, - test_data.first_record.offset - ); - assert_eq!( - computed_zoneinfo.initial_record.designation, - test_data.first_record.abbr - ); - - // zip() stops at the shorter side, so compare counts first. - assert_eq!( - computed_zoneinfo.transitions.iter().count(), - test_data.transitions.len(), - "Transition counts are not aligned for {identifier}" - ); - - for (computed, td) in computed_zoneinfo - .transitions - .iter() - .zip(test_data.transitions) - { - assert_eq!( - computed.at_time, td.transition_time, - "Transition times are not aligned for {}", - td.transition_time, - ); - assert_eq!( - computed.offset, td.record.offset, - "Offsets are not aligned for {}", - td.transition_time - ); - assert_eq!( - computed.dst, td.record.is_dst, - "DST flag is not aligned for {}", - td.transition_time - ); - assert_eq!( - computed.format, td.record.abbr, - "Designation is not aligned for {}", - td.transition_time - ); +/// Zones where zic materializes transitions `zoneinfo_rs` does not generate. +/// +/// `Rule Palestine 2072 max` is interleaved with Ramadan-derived `only` rules +/// through 2086. The compiler stops at the first `max` rule and defers the rest +/// to the POSIX footer, which has no Ramadan pause. The shared prefix is still +/// compared. +const UNDER_GENERATED: &[&str] = &["Asia/Gaza", "Asia/Hebron"]; + +/// Ground truth transitions with zic's trailing footer anchor removed. +/// +/// glibc 2.43's zic ends the table with a no-op transition — same offset, DST +/// flag and designation as its predecessor — and omits the next real one, +/// which the footer already expresses; glibc 2.39's zic did the opposite. +/// +/// The anchor sits on a zone-line boundary the compiler collapses, so only a +/// final repeat at an instant we do not generate qualifies. A repeat we do +/// generate is real, like `America/Nuuk`'s last transition. +fn significant<'a>( + test_data: &'a TzifTestData, + computed: &CompiledTransitions, +) -> &'a [TransitionRecord] { + let transitions = &test_data.transitions[..]; + let [.., previous, last] = transitions else { + return transitions; + }; + let anchor = previous.record.offset == last.record.offset + && previous.record.is_dst == last.record.is_dst + && previous.record.abbr == last.record.abbr + && !computed + .transitions + .iter() + .any(|t| t.at_time == last.transition_time); + if anchor { + &transitions[..transitions.len() - 1] + } else { + transitions } } -/// Returns None on pass, Some(error) on failure; skips zones with no JSON. -fn check_zone( +/// Compares a compiled zone against zic's table, one message per mismatch. +/// +/// Ground truth may stop early — past its last entry is footer territory. +/// Running past *our* last transition is under-generation, and fails unless the +/// zone is a documented [`UNDER_GENERATED`] gap. +fn compare( identifier: &str, - compiler: &mut ZoneInfoCompiler, - data_dir: &Path, -) -> Option { - let test_json = identifier.replace("/", "-").to_ascii_lowercase(); - let path = data_dir.join(format!("{test_json}.json")); - let Ok(content) = std::fs::read_to_string(&path) else { - return None; // no JSON yet — skip - }; - let Ok(test_data) = serde_json::from_str::(&content) else { - return Some(format!("{identifier}: malformed JSON")); - }; - - let computed = compiler.build_zone(identifier); + computed: &CompiledTransitions, + test_data: &TzifTestData, +) -> Vec { let mut errors: Vec = Vec::new(); if computed.initial_record.offset != test_data.first_record.offset { @@ -99,24 +84,22 @@ fn check_zone( computed.initial_record.offset, test_data.first_record.offset )); } - - // zip() stops at the shorter side, so compare counts first. - let computed_count = computed.transitions.iter().count(); - if computed_count != test_data.transitions.len() { + if computed.initial_record.designation != test_data.first_record.abbr { errors.push(format!( - "transition count {} != {}", - computed_count, - test_data.transitions.len() + "initial abbr {:?} != {:?}", + computed.initial_record.designation, test_data.first_record.abbr )); } - for (c, td) in computed.transitions.iter().zip(&test_data.transitions) { + let expected = significant(test_data, computed); + + for (c, td) in computed.transitions.iter().zip(expected) { if c.at_time != td.transition_time { errors.push(format!( - "transition time {} != {} (at {})", - c.at_time, td.transition_time, td.transition_time + "transition time {} != {}", + c.at_time, td.transition_time )); - break; + break; // The lists have desynchronized; later diffs are noise. } if c.offset != td.record.offset { errors.push(format!( @@ -138,6 +121,43 @@ fn check_zone( } } + let missing = expected.len().saturating_sub(computed.transitions.len()); + if missing != 0 && !UNDER_GENERATED.contains(&identifier) { + errors.push(format!( + "{missing} transitions missing after {} (zic ends at {})", + computed.transitions.iter().last().map_or(-1, |t| t.at_time), + expected.last().map_or(-1, |t| t.transition_time) + )); + } + + errors +} + +fn run_assertions(identifier: &str, zoneinfo_data: ZoneInfoData, test_data: TzifTestData) { + let mut compiler = ZoneInfoCompiler::new(zoneinfo_data); + let computed = compiler.build_zone(identifier); + let errors = compare(identifier, &computed, &test_data); + assert!(errors.is_empty(), "{identifier}: {}", errors.join("; ")); +} + +/// Returns None on pass, Some(error) on failure; skips zones with no JSON. +fn check_zone( + identifier: &str, + compiler: &mut ZoneInfoCompiler, + data_dir: &Path, +) -> Option { + let test_json = identifier.replace("/", "-").to_ascii_lowercase(); + let path = data_dir.join(format!("{test_json}.json")); + let Ok(content) = std::fs::read_to_string(&path) else { + return None; // no JSON yet — skip + }; + let Ok(test_data) = serde_json::from_str::(&content) else { + return Some(format!("{identifier}: malformed JSON")); + }; + + let computed = compiler.build_zone(identifier); + let errors = compare(identifier, &computed, &test_data); + if errors.is_empty() { None } else {