From 620a287ed8804e4c36d3d8a6feeaecc456bccae2 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Tue, 14 Jul 2026 21:40:02 +0800 Subject: [PATCH] test: model public relative semantics --- .agents/docs/testing-strategy.md | 2 + tests/public_relative_model.rs | 330 +++++++++++++++++++++++++++++++ 2 files changed, 332 insertions(+) create mode 100644 tests/public_relative_model.rs diff --git a/.agents/docs/testing-strategy.md b/.agents/docs/testing-strategy.md index d5bed9a..bf11b94 100644 --- a/.agents/docs/testing-strategy.md +++ b/.agents/docs/testing-strategy.md @@ -31,6 +31,7 @@ The matrix is interaction-aware. A platform root kind must be crossed with encod - `relative_with` has fixed Unix and Windows expected-output matrices for relative, absolute, mixed-context, dirty, equal, root-clamped, trailing-separator, root-relative, drive-relative, and different-drive results, using both borrowed and owned cwd arguments. - Cwd-independent rows pin the same literal result through `relative`, `try_relative`, and both explicit-cwd argument forms while proving that an unused non-absolute cwd is not validated. - `try_relative` and `relative_with` assert borrowed descendant and equal suffixes, owned upward and dirty results, owned cwd-resolved results, and receiver-only borrowing when base and cwd are owned temporaries. +- An independent public `relative_with` model resolves its own root and component structures without calling SugarPath or `std::path::components`, then checks 40,368 Unix and 161,472 Windows combinations across clean and dirty native spellings. Its dirty absolute cwd contains `..`; Unix comparison is exact, while Windows roots and components compare with ASCII case ignored. - On macOS and Linux, all 224,676 pairs from the bounded short absolute spelling set plus the multibyte set are checked through the production `relative_str` dispatch and the suffix-validation helper against the slow component oracle. - Unavailable Unix cwd coverage pins exact successful output and `Cow` variants for cwd-independent fallible calls, preserves the ambient error and panic checks for dependent calls, and proves that a valid explicit cwd still succeeds. @@ -53,6 +54,7 @@ Any change to a public contract, platform branch, native-encoding comparison, cl - [Native CI target and sentinel checks](../../.github/workflows/test.yaml) - [Default/all-feature configuration sentinel](../../tests/feature_configuration.rs) - [Fixed explicit and fallible relative matrices](../../tests/relative_lexical.rs) +- [Independent bounded public relative model](../../tests/public_relative_model.rs) - [Relative ownership and lifetime contracts](../../tests/relative_borrowing.rs) - [Unavailable-cwd relative behavior](../../tests/relative_without_cwd.rs) - [Production dispatch and short-path oracle](../../src/impl_sugar_path.rs) diff --git a/tests/public_relative_model.rs b/tests/public_relative_model.rs new file mode 100644 index 0000000..5cd6152 --- /dev/null +++ b/tests/public_relative_model.rs @@ -0,0 +1,330 @@ +#![cfg(any(target_family = "unix", target_family = "windows"))] + +use std::path::{Path, PathBuf}; + +use sugar_path::SugarPath; + +const FULL_ATOMS: &[Atom] = + &[Atom::Name("a"), Atom::Name("A"), Atom::Name("b"), Atom::Current, Atom::Parent]; +const STACK_ATOMS: &[Atom] = &[Atom::Name("a"), Atom::Current, Atom::Parent]; + +#[cfg(target_family = "unix")] +const EXPECTED_COMPARISONS: usize = 40_368; +#[cfg(target_family = "windows")] +const EXPECTED_COMPARISONS: usize = 161_472; + +#[derive(Clone, Copy, Debug)] +enum Atom { + Name(&'static str), + Current, + Parent, +} + +impl Atom { + fn spelling(self) -> &'static str { + match self { + Self::Name(name) => name, + Self::Current => ".", + Self::Parent => "..", + } + } +} + +#[derive(Clone, Copy, Debug)] +enum InputRoot { + Relative, + #[cfg(target_family = "unix")] + Unix, + #[cfg(target_family = "windows")] + RootRelative, + #[cfg(target_family = "windows")] + Disk(u8), +} + +#[derive(Clone, Debug)] +struct InputCase { + root: InputRoot, + atoms: Vec, +} + +#[derive(Clone, Copy, Debug)] +enum ResolvedRoot { + #[cfg(target_family = "unix")] + Unix, + #[cfg(target_family = "windows")] + Disk(u8), +} + +#[derive(Clone, Debug)] +struct ResolvedPath { + root: ResolvedRoot, + components: Vec<&'static str>, +} + +#[derive(Clone, Copy, Debug)] +enum Spelling { + Clean, + Dirty, +} + +impl Spelling { + fn separator(self) -> &'static str { + match self { + Self::Clean => native_separator(), + Self::Dirty => redundant_separator(), + } + } + + fn has_trailing_separator(self) -> bool { + matches!(self, Self::Dirty) + } +} + +#[derive(Clone, Copy, Debug)] +struct Mode { + name: &'static str, + target: Spelling, + base: Spelling, +} + +const MODES: &[Mode] = &[ + Mode { name: "clean", target: Spelling::Clean, base: Spelling::Clean }, + Mode { name: "dirty_target", target: Spelling::Dirty, base: Spelling::Clean }, + Mode { name: "dirty_base", target: Spelling::Clean, base: Spelling::Dirty }, +]; + +#[cfg(target_family = "unix")] +fn platform_name() -> &'static str { + "unix" +} + +#[cfg(target_family = "windows")] +fn platform_name() -> &'static str { + "windows" +} + +#[cfg(target_family = "unix")] +fn native_separator() -> &'static str { + "/" +} + +#[cfg(target_family = "windows")] +fn native_separator() -> &'static str { + r"\" +} + +#[cfg(target_family = "unix")] +fn redundant_separator() -> &'static str { + "//" +} + +#[cfg(target_family = "windows")] +fn redundant_separator() -> &'static str { + r"\\" +} + +fn roots() -> &'static [InputRoot] { + #[cfg(target_family = "unix")] + { + &[InputRoot::Relative, InputRoot::Unix] + } + #[cfg(target_family = "windows")] + { + &[InputRoot::Relative, InputRoot::RootRelative, InputRoot::Disk(b'C'), InputRoot::Disk(b'D')] + } +} + +fn generated_atom_sequences() -> Vec> { + let mut sequences = Vec::new(); + for depth in 0..=2 { + append_sequences(&mut sequences, FULL_ATOMS, depth); + } + append_sequences(&mut sequences, STACK_ATOMS, 3); + sequences +} + +fn append_sequences(sequences: &mut Vec>, alphabet: &[Atom], depth: u32) { + for mut ordinal in 0..alphabet.len().pow(depth) { + let mut sequence = Vec::with_capacity(depth as usize); + for _ in 0..depth { + sequence.push(alphabet[ordinal % alphabet.len()]); + ordinal /= alphabet.len(); + } + sequences.push(sequence); + } +} + +fn generated_cases() -> Vec { + generated_atom_sequences() + .into_iter() + .flat_map(|atoms| { + roots().iter().map(move |root| InputCase { root: *root, atoms: atoms.clone() }) + }) + .collect() +} + +fn dirty_cwd_case() -> InputCase { + InputCase { + #[cfg(target_family = "unix")] + root: InputRoot::Unix, + #[cfg(target_family = "windows")] + root: InputRoot::Disk(b'C'), + atoms: vec![Atom::Name("root"), Atom::Name("x"), Atom::Parent, Atom::Name("y")], + } +} + +fn render_input(case: &InputCase, spelling: Spelling) -> String { + let mut rendered = match case.root { + InputRoot::Relative => String::new(), + #[cfg(target_family = "unix")] + InputRoot::Unix => String::from("/"), + #[cfg(target_family = "windows")] + InputRoot::RootRelative => String::from(r"\"), + #[cfg(target_family = "windows")] + InputRoot::Disk(drive) => format!("{}:\\", char::from(drive)), + }; + rendered.push_str( + &case.atoms.iter().map(|atom| atom.spelling()).collect::>().join(spelling.separator()), + ); + if spelling.has_trailing_separator() && !case.atoms.is_empty() { + rendered.push_str(spelling.separator()); + } + rendered +} + +fn resolve_cwd(case: &InputCase) -> ResolvedPath { + let root = match case.root { + InputRoot::Relative => panic!("the modeled cwd must be absolute"), + #[cfg(target_family = "unix")] + InputRoot::Unix => ResolvedRoot::Unix, + #[cfg(target_family = "windows")] + InputRoot::RootRelative => panic!("the modeled Windows cwd must include a disk"), + #[cfg(target_family = "windows")] + InputRoot::Disk(drive) => ResolvedRoot::Disk(drive), + }; + let mut resolved = ResolvedPath { root, components: Vec::new() }; + apply_atoms(&mut resolved.components, &case.atoms); + resolved +} + +fn resolve_input(case: &InputCase, cwd: &ResolvedPath) -> ResolvedPath { + let mut resolved = match case.root { + InputRoot::Relative => cwd.clone(), + #[cfg(target_family = "unix")] + InputRoot::Unix => ResolvedPath { root: ResolvedRoot::Unix, components: Vec::new() }, + #[cfg(target_family = "windows")] + InputRoot::RootRelative => ResolvedPath { root: cwd.root, components: Vec::new() }, + #[cfg(target_family = "windows")] + InputRoot::Disk(drive) => { + ResolvedPath { root: ResolvedRoot::Disk(drive), components: Vec::new() } + } + }; + apply_atoms(&mut resolved.components, &case.atoms); + resolved +} + +fn apply_atoms(components: &mut Vec<&'static str>, atoms: &[Atom]) { + for atom in atoms { + match *atom { + Atom::Name(name) => components.push(name), + Atom::Current => {} + Atom::Parent => { + components.pop(); + } + } + } +} + +fn roots_equal(left: ResolvedRoot, right: ResolvedRoot) -> bool { + #[cfg(target_family = "unix")] + { + matches!((left, right), (ResolvedRoot::Unix, ResolvedRoot::Unix)) + } + #[cfg(target_family = "windows")] + { + match (left, right) { + (ResolvedRoot::Disk(left), ResolvedRoot::Disk(right)) => left.eq_ignore_ascii_case(&right), + } + } +} + +fn components_equal(left: &str, right: &str) -> bool { + #[cfg(target_family = "unix")] + { + left == right + } + #[cfg(target_family = "windows")] + { + left.eq_ignore_ascii_case(right) + } +} + +fn expected_relative(target: &ResolvedPath, base: &ResolvedPath) -> PathBuf { + if !roots_equal(target.root, base.root) { + return render_resolved_absolute(target); + } + + let common = target + .components + .iter() + .zip(&base.components) + .take_while(|(target, base)| components_equal(target, base)) + .count(); + let components = std::iter::repeat_n("..", base.components.len() - common) + .chain(target.components[common..].iter().copied()) + .collect::>(); + PathBuf::from(components.join(native_separator())) +} + +fn render_resolved_absolute(path: &ResolvedPath) -> PathBuf { + let suffix = path.components.join(native_separator()); + match path.root { + #[cfg(target_family = "unix")] + ResolvedRoot::Unix => { + if suffix.is_empty() { + PathBuf::from("/") + } else { + PathBuf::from(format!("/{suffix}")) + } + } + #[cfg(target_family = "windows")] + ResolvedRoot::Disk(drive) => PathBuf::from(format!("{}:\\{suffix}", char::from(drive))), + } +} + +#[test] +fn public_relative_with_matches_the_bounded_independent_model() { + let cases = generated_cases(); + let cwd_case = dirty_cwd_case(); + let cwd = render_input(&cwd_case, Spelling::Clean); + let resolved_cwd = resolve_cwd(&cwd_case); + let resolved_cases = + cases.iter().map(|case| resolve_input(case, &resolved_cwd)).collect::>(); + let mut comparisons = 0; + + for mode in MODES { + let rendered_targets = + cases.iter().map(|case| render_input(case, mode.target)).collect::>(); + let rendered_bases = cases.iter().map(|case| render_input(case, mode.base)).collect::>(); + + for (target_index, target) in rendered_targets.iter().enumerate() { + for (base_index, base) in rendered_bases.iter().enumerate() { + let expected = + expected_relative(&resolved_cases[target_index], &resolved_cases[base_index]); + let actual = Path::new(target).relative_with(Path::new(base), Path::new(&cwd)); + comparisons += 1; + assert_eq!( + actual.as_os_str(), + expected.as_os_str(), + "platform={}; mode={}; target={target:?}; base={base:?}; cwd={cwd:?}; expected={:?}; actual={:?}", + platform_name(), + mode.name, + expected.as_os_str(), + actual.as_os_str(), + ); + } + } + } + + assert_eq!(comparisons, EXPECTED_COMPARISONS); +}