Skip to main content

azul_css/
dynamic_selector.rs

1//! Dynamic CSS selectors for runtime evaluation based on OS, media queries, container queries, etc.
2
3use crate::corety::{AzString, OptionString};
4use crate::props::property::CssProperty;
5
6/// State flags for pseudo-classes (used in `DynamicSelectorContext`)
7/// Note: This is a CSS-only version. See `azul_core::styled_dom::StyledNodeState` for the main type.
8//
9// TODO(superplan g8 item 3): unify with `azul_core::styled_dom::StyledNodeState`
10// (core/src/styled_dom.rs:190). The two structs now carry the *identical* 10 fields
11// (hover/active/focused/disabled/checked/focus_within/visited/backdrop/dragging/
12// drag_over) and core already bridges them via `StyledNodeState::from_pseudo_state_flags`.
13// `azul_css` cannot depend on `azul_core`, so the merge must land core-side (e.g. move
14// the shared struct into `azul_css` and re-export from core, or delete one type). This is
15// a cross-crate change touching core/, left as a TODO per group ownership.
16#[repr(C)]
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
18pub struct PseudoStateFlags {
19    pub hover: bool,
20    pub active: bool,
21    pub focused: bool,
22    pub disabled: bool,
23    pub checked: bool,
24    pub focus_within: bool,
25    pub visited: bool,
26    /// Window is not focused (equivalent to GTK :backdrop)
27    pub backdrop: bool,
28    /// Element is currently being dragged (:dragging)
29    pub dragging: bool,
30    /// A dragged element is over this drop target (:drag-over)
31    pub drag_over: bool,
32}
33
34impl PseudoStateFlags {
35    /// Check if a specific pseudo-state is active
36    #[must_use] pub const fn has_state(&self, state: PseudoStateType) -> bool {
37        match state {
38            PseudoStateType::Normal => true,
39            PseudoStateType::Hover => self.hover,
40            PseudoStateType::Active => self.active,
41            PseudoStateType::Focus => self.focused,
42            PseudoStateType::Disabled => self.disabled,
43            PseudoStateType::CheckedTrue => self.checked,
44            PseudoStateType::CheckedFalse => !self.checked,
45            PseudoStateType::FocusWithin => self.focus_within,
46            PseudoStateType::Visited => self.visited,
47            PseudoStateType::Backdrop => self.backdrop,
48            PseudoStateType::Dragging => self.dragging,
49            PseudoStateType::DragOver => self.drag_over,
50        }
51    }
52}
53
54/// Dynamic selector that is evaluated at runtime
55/// C-compatible: Tagged union with single field
56#[repr(C, u8)]
57#[derive(Debug, Clone, PartialEq)]
58pub enum DynamicSelector {
59    /// Operating system condition
60    Os(OsCondition) = 0,
61    /// Operating system version (e.g. macOS 14.0, Windows 11)
62    OsVersion(OsVersionCondition) = 1,
63    /// Media query (print/screen)
64    Media(MediaType) = 2,
65    /// Viewport width min/max (for @media)
66    ViewportWidth(MinMaxRange) = 3,
67    /// Viewport height min/max (for @media)
68    ViewportHeight(MinMaxRange) = 4,
69    /// Container width min/max (for @container)
70    ContainerWidth(MinMaxRange) = 5,
71    /// Container height min/max (for @container)
72    ContainerHeight(MinMaxRange) = 6,
73    /// Container name (for named @container queries)
74    ContainerName(AzString) = 7,
75    /// Theme (dark/light/custom)
76    Theme(ThemeCondition) = 8,
77    /// Aspect Ratio (min/max for @media and @container)
78    AspectRatio(MinMaxRange) = 9,
79    /// Orientation (portrait/landscape)
80    Orientation(OrientationType) = 10,
81    /// Reduced Motion (accessibility)
82    PrefersReducedMotion(BoolCondition) = 11,
83    /// High Contrast (accessibility)
84    PrefersHighContrast(BoolCondition) = 12,
85    /// Pseudo-State (hover, active, focus, etc.)
86    PseudoState(PseudoStateType) = 13,
87    /// Language/Locale (for @lang("de-DE"))
88    /// Matches BCP 47 language tags (e.g., "de", "de-DE", "en-US")
89    Language(LanguageCondition) = 14,
90}
91
92impl_option!(
93    DynamicSelector,
94    OptionDynamicSelector,
95    copy = false,
96    [Debug, Clone, PartialEq, Eq]
97);
98
99impl_vec!(DynamicSelector, DynamicSelectorVec, DynamicSelectorVecDestructor, DynamicSelectorVecDestructorType, DynamicSelectorVecSlice, OptionDynamicSelector);
100impl_vec_clone!(
101    DynamicSelector,
102    DynamicSelectorVec,
103    DynamicSelectorVecDestructor
104);
105impl_vec_debug!(DynamicSelector, DynamicSelectorVec);
106impl_vec_partialeq!(DynamicSelector, DynamicSelectorVec);
107
108impl DynamicSelector {
109    /// Stable per-variant tag (mirrors the `#[repr(C, u8)]` discriminants), used as
110    /// the primary key for both `Ord` and `Hash` so the two stay consistent.
111    const fn variant_tag(&self) -> u8 {
112        match self {
113            Self::Os(_) => 0,
114            Self::OsVersion(_) => 1,
115            Self::Media(_) => 2,
116            Self::ViewportWidth(_) => 3,
117            Self::ViewportHeight(_) => 4,
118            Self::ContainerWidth(_) => 5,
119            Self::ContainerHeight(_) => 6,
120            Self::ContainerName(_) => 7,
121            Self::Theme(_) => 8,
122            Self::AspectRatio(_) => 9,
123            Self::Orientation(_) => 10,
124            Self::PrefersReducedMotion(_) => 11,
125            Self::PrefersHighContrast(_) => 12,
126            Self::PseudoState(_) => 13,
127            Self::Language(_) => 14,
128        }
129    }
130}
131
132// `DynamicSelector` carries `f32` ranges (`MinMaxRange`), so `Eq`/`Ord`/`Hash`
133// cannot be derived. They are implemented by hand here: every non-float payload
134// already provides them, and the float ranges are compared/hashed by their bit
135// pattern so the resulting order is *total* and consistent with `Hash`. (Bit
136// comparison means NaN sentinels sort deterministically instead of being
137// incomparable.)
138impl Eq for DynamicSelector {}
139
140impl PartialOrd for DynamicSelector {
141    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
142        Some(self.cmp(other))
143    }
144}
145
146impl Ord for DynamicSelector {
147    // Order-dependent tie-break arms with identical bodies can't merge without
148    // changing the ordering (clippy::match_same_arms false positive).
149    #[allow(clippy::match_same_arms)]
150    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
151        use core::cmp::Ordering;
152        match self.variant_tag().cmp(&other.variant_tag()) {
153            Ordering::Equal => {}
154            non_eq => return non_eq,
155        }
156        // Same variant on both sides (tags are equal): compare the payloads.
157        match (self, other) {
158            (Self::Os(a), Self::Os(b)) => a.cmp(b),
159            (Self::OsVersion(a), Self::OsVersion(b)) => a.cmp(b),
160            (Self::Media(a), Self::Media(b)) => a.cmp(b),
161            (Self::ContainerName(a), Self::ContainerName(b)) => a.cmp(b),
162            (Self::Theme(a), Self::Theme(b)) => a.cmp(b),
163            (Self::Orientation(a), Self::Orientation(b)) => a.cmp(b),
164            (Self::PrefersReducedMotion(a), Self::PrefersReducedMotion(b)) => {
165                a.cmp(b)
166            }
167            (Self::PrefersHighContrast(a), Self::PrefersHighContrast(b)) => {
168                a.cmp(b)
169            }
170            (Self::PseudoState(a), Self::PseudoState(b)) => a.cmp(b),
171            (Self::Language(a), Self::Language(b)) => a.cmp(b),
172            (Self::ViewportWidth(a), Self::ViewportWidth(b))
173            | (Self::ViewportHeight(a), Self::ViewportHeight(b))
174            | (Self::ContainerWidth(a), Self::ContainerWidth(b))
175            | (Self::ContainerHeight(a), Self::ContainerHeight(b))
176            | (Self::AspectRatio(a), Self::AspectRatio(b)) => {
177                (a.min.to_bits(), a.max.to_bits()).cmp(&(b.min.to_bits(), b.max.to_bits()))
178            }
179            // Unreachable: tags are equal, so both sides are the same variant.
180            _ => Ordering::Equal,
181        }
182    }
183}
184
185impl core::hash::Hash for DynamicSelector {
186    // Per-variant dispatch: each `x` is a different type, so the identical
187    // `x.hash(state)` bodies can't merge (clippy::match_same_arms false positive).
188    #[allow(clippy::match_same_arms)]
189    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
190        self.variant_tag().hash(state);
191        match self {
192            Self::Os(x) => x.hash(state),
193            Self::OsVersion(x) => x.hash(state),
194            Self::Media(x) => x.hash(state),
195            Self::ContainerName(x) => x.hash(state),
196            Self::Theme(x) => x.hash(state),
197            Self::Orientation(x) => x.hash(state),
198            Self::PrefersReducedMotion(x) => x.hash(state),
199            Self::PrefersHighContrast(x) => x.hash(state),
200            Self::PseudoState(x) => x.hash(state),
201            Self::Language(x) => x.hash(state),
202            Self::ViewportWidth(r)
203            | Self::ViewportHeight(r)
204            | Self::ContainerWidth(r)
205            | Self::ContainerHeight(r)
206            | Self::AspectRatio(r) => {
207                r.min.to_bits().hash(state);
208                r.max.to_bits().hash(state);
209            }
210        }
211    }
212}
213
214/// Min/Max Range for numeric conditions (C-compatible)
215#[repr(C)]
216#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
217pub struct MinMaxRange {
218    /// Minimum value (NaN = no minimum limit)
219    pub min: f32,
220    /// Maximum value (NaN = no maximum limit)
221    pub max: f32,
222}
223
224impl MinMaxRange {
225    #[must_use] pub const fn new(min: Option<f32>, max: Option<f32>) -> Self {
226        Self {
227            min: if let Some(m) = min { m } else { f32::NAN },
228            max: if let Some(m) = max { m } else { f32::NAN },
229        }
230    }
231    
232    /// Create a range with only a minimum value (>= min)
233    #[must_use] pub const fn with_min(min_val: f32) -> Self {
234        Self {
235            min: min_val,
236            max: f32::NAN,
237        }
238    }
239    
240    /// Create a range with only a maximum value (<= max)
241    #[must_use] pub const fn with_max(max_val: f32) -> Self {
242        Self {
243            min: f32::NAN,
244            max: max_val,
245        }
246    }
247
248    #[must_use] pub const fn min(&self) -> Option<f32> {
249        if self.min.is_nan() {
250            None
251        } else {
252            Some(self.min)
253        }
254    }
255
256    #[must_use] pub const fn max(&self) -> Option<f32> {
257        if self.max.is_nan() {
258            None
259        } else {
260            Some(self.max)
261        }
262    }
263
264    #[must_use] pub fn matches(&self, value: f32) -> bool {
265        let min_ok = self.min.is_nan() || value >= self.min;
266        let max_ok = self.max.is_nan() || value <= self.max;
267        min_ok && max_ok
268    }
269}
270
271/// Boolean condition (C-compatible)
272#[repr(C)]
273#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, PartialOrd, Ord)]
274pub enum BoolCondition {
275    #[default]
276    False,
277    True,
278}
279
280impl From<bool> for BoolCondition {
281    fn from(b: bool) -> Self {
282        if b {
283            Self::True
284        } else {
285            Self::False
286        }
287    }
288}
289
290impl From<BoolCondition> for bool {
291    fn from(b: BoolCondition) -> Self {
292        matches!(b, BoolCondition::True)
293    }
294}
295
296/// Operating system condition for `@os` CSS selectors
297#[repr(C)]
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
299pub enum OsCondition {
300    Any,
301    Apple, // macOS + iOS
302    MacOS,
303    IOS,
304    Linux,
305    Windows,
306    Android,
307    Web, // WASM
308}
309
310impl_option!(
311    OsCondition,
312    OptionOsCondition,
313    [Debug, Clone, Copy, PartialEq, Eq, Hash]
314);
315
316impl OsCondition {
317    /// Convert from `css::system::Platform`
318    #[must_use] pub const fn from_system_platform(platform: &crate::system::Platform) -> Self {
319        use crate::system::Platform;
320        match platform {
321            Platform::Windows => Self::Windows,
322            Platform::MacOs => Self::MacOS,
323            Platform::Linux(_) => Self::Linux,
324            Platform::Android => Self::Android,
325            Platform::Ios => Self::IOS,
326            Platform::Unknown => Self::Any,
327        }
328    }
329}
330
331#[repr(C, u8)]
332#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
333pub enum OsVersionCondition {
334    /// Minimum version: >= specified version
335    /// Format: `OsVersion` { os, `version_id` }
336    Min(OsVersion),
337    /// Maximum version: <= specified version
338    Max(OsVersion),
339    /// Exact version match
340    Exact(OsVersion),
341    /// Desktop environment (Linux only)
342    DesktopEnvironment(LinuxDesktopEnv),
343    /// Desktop environment with min version (e.g. `@os(linux:gnome > 40)`)
344    DesktopEnvMin(DesktopEnvVersion),
345    /// Desktop environment with max version
346    DesktopEnvMax(DesktopEnvVersion),
347    /// Desktop environment with exact version
348    DesktopEnvExact(DesktopEnvVersion),
349}
350
351/// A desktop environment together with a numeric version (e.g. GNOME 40).
352/// Used by `OsVersionCondition::DesktopEnv{Min,Max,Exact}` for `@os(linux:gnome > 40)` style selectors.
353#[repr(C)]
354#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
355pub struct DesktopEnvVersion {
356    pub env: LinuxDesktopEnv,
357    pub version_id: u32,
358}
359
360/// OS version with ordering - only comparable within the same OS family
361/// 
362/// Each OS has its own version numbering system with named versions.
363/// Comparisons between different OS families always return false.
364#[repr(C)]
365#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
366pub struct OsVersion {
367    /// Which OS family this version belongs to
368    pub os: OsFamily,
369    /// Numeric version ID for ordering (higher = newer)
370    /// Each OS has its own numbering scheme starting from 0
371    pub version_id: u32,
372}
373
374impl Default for OsVersion {
375    fn default() -> Self {
376        Self::unknown()
377    }
378}
379
380impl OsVersion {
381    #[must_use] pub const fn new(os: OsFamily, version_id: u32) -> Self {
382        Self { os, version_id }
383    }
384    
385    /// Compare two versions - only meaningful within the same OS family
386    /// Returns None if OS families don't match (comparison not meaningful)
387    #[must_use] pub fn compare(&self, other: &Self) -> Option<core::cmp::Ordering> {
388        if self.os == other.os {
389            Some(self.version_id.cmp(&other.version_id))
390        } else {
391            None // Cross-OS comparison not meaningful
392        }
393    }
394    
395    /// Check if self >= other (for Min conditions)
396    #[must_use] pub fn is_at_least(&self, other: &Self) -> bool {
397        self.compare(other).is_some_and(|o| o != core::cmp::Ordering::Less)
398    }
399    
400    /// Check if self <= other (for Max conditions)
401    #[must_use] pub fn is_at_most(&self, other: &Self) -> bool {
402        self.compare(other).is_some_and(|o| o != core::cmp::Ordering::Greater)
403    }
404}
405
406impl_option!(
407    OsVersion,
408    OptionOsVersion,
409    [Debug, Clone, Copy, PartialEq, Eq, Hash]
410);
411
412impl OsVersion {
413    
414    /// Check if self == other
415    #[must_use] pub fn is_exactly(&self, other: &Self) -> bool {
416        self.compare(other) == Some(core::cmp::Ordering::Equal)
417    }
418}
419
420/// OS family for version comparisons
421#[repr(C)]
422#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
423pub enum OsFamily {
424    Windows,
425    MacOS,
426    IOS,
427    Linux,
428    Android,
429}
430
431// ============================================================================
432// Windows Version IDs (chronological order)
433// ============================================================================
434
435/// Windows version constants - use these in CSS like `@os(windows >= win-xp)`
436impl OsVersion {
437    // Windows versions (version_id = NT version * 100 + minor)
438    pub const WIN_2000: Self = Self::new(OsFamily::Windows, 500);       // NT 5.0
439    pub const WIN_XP: Self = Self::new(OsFamily::Windows, 501);         // NT 5.1
440    pub const WIN_XP_64: Self = Self::new(OsFamily::Windows, 502);      // NT 5.2
441    pub const WIN_VISTA: Self = Self::new(OsFamily::Windows, 600);      // NT 6.0
442    pub const WIN_7: Self = Self::new(OsFamily::Windows, 601);          // NT 6.1
443    pub const WIN_8: Self = Self::new(OsFamily::Windows, 602);          // NT 6.2
444    pub const WIN_8_1: Self = Self::new(OsFamily::Windows, 603);        // NT 6.3
445    pub const WIN_10: Self = Self::new(OsFamily::Windows, 1000);        // NT 10.0
446    pub const WIN_10_1507: Self = Self::new(OsFamily::Windows, 1000);   // Initial release
447    pub const WIN_10_1511: Self = Self::new(OsFamily::Windows, 1001);   // November Update
448    pub const WIN_10_1607: Self = Self::new(OsFamily::Windows, 1002);   // Anniversary Update
449    pub const WIN_10_1703: Self = Self::new(OsFamily::Windows, 1003);   // Creators Update
450    pub const WIN_10_1709: Self = Self::new(OsFamily::Windows, 1004);   // Fall Creators Update
451    pub const WIN_10_1803: Self = Self::new(OsFamily::Windows, 1005);   // April 2018 Update
452    pub const WIN_10_1809: Self = Self::new(OsFamily::Windows, 1006);   // October 2018 Update
453    pub const WIN_10_1903: Self = Self::new(OsFamily::Windows, 1007);   // May 2019 Update
454    pub const WIN_10_1909: Self = Self::new(OsFamily::Windows, 1008);   // November 2019 Update
455    pub const WIN_10_2004: Self = Self::new(OsFamily::Windows, 1009);   // May 2020 Update
456    pub const WIN_10_20H2: Self = Self::new(OsFamily::Windows, 1010);   // October 2020 Update
457    pub const WIN_10_21H1: Self = Self::new(OsFamily::Windows, 1011);   // May 2021 Update
458    pub const WIN_10_21H2: Self = Self::new(OsFamily::Windows, 1012);   // November 2021 Update
459    pub const WIN_10_22H2: Self = Self::new(OsFamily::Windows, 1013);   // 2022 Update
460    pub const WIN_11: Self = Self::new(OsFamily::Windows, 1100);        // Windows 11 base
461    pub const WIN_11_21H2: Self = Self::new(OsFamily::Windows, 1100);   // Initial release
462    pub const WIN_11_22H2: Self = Self::new(OsFamily::Windows, 1101);   // 2022 Update
463    pub const WIN_11_23H2: Self = Self::new(OsFamily::Windows, 1102);   // 2023 Update
464    pub const WIN_11_24H2: Self = Self::new(OsFamily::Windows, 1103);   // 2024 Update
465    
466    // macOS versions (version_id = major * 100 + minor)
467    pub const MACOS_CHEETAH: Self = Self::new(OsFamily::MacOS, 1000);       // 10.0
468    pub const MACOS_PUMA: Self = Self::new(OsFamily::MacOS, 1001);          // 10.1
469    pub const MACOS_JAGUAR: Self = Self::new(OsFamily::MacOS, 1002);        // 10.2
470    pub const MACOS_PANTHER: Self = Self::new(OsFamily::MacOS, 1003);       // 10.3
471    pub const MACOS_TIGER: Self = Self::new(OsFamily::MacOS, 1004);         // 10.4
472    pub const MACOS_LEOPARD: Self = Self::new(OsFamily::MacOS, 1005);       // 10.5
473    pub const MACOS_SNOW_LEOPARD: Self = Self::new(OsFamily::MacOS, 1006);  // 10.6
474    pub const MACOS_LION: Self = Self::new(OsFamily::MacOS, 1007);          // 10.7
475    pub const MACOS_MOUNTAIN_LION: Self = Self::new(OsFamily::MacOS, 1008); // 10.8
476    pub const MACOS_MAVERICKS: Self = Self::new(OsFamily::MacOS, 1009);     // 10.9
477    pub const MACOS_YOSEMITE: Self = Self::new(OsFamily::MacOS, 1010);      // 10.10
478    pub const MACOS_EL_CAPITAN: Self = Self::new(OsFamily::MacOS, 1011);    // 10.11
479    pub const MACOS_SIERRA: Self = Self::new(OsFamily::MacOS, 1012);        // 10.12
480    pub const MACOS_HIGH_SIERRA: Self = Self::new(OsFamily::MacOS, 1013);   // 10.13
481    pub const MACOS_MOJAVE: Self = Self::new(OsFamily::MacOS, 1014);        // 10.14
482    pub const MACOS_CATALINA: Self = Self::new(OsFamily::MacOS, 1015);      // 10.15
483    pub const MACOS_BIG_SUR: Self = Self::new(OsFamily::MacOS, 1100);       // 11.0
484    pub const MACOS_MONTEREY: Self = Self::new(OsFamily::MacOS, 1200);      // 12.0
485    pub const MACOS_VENTURA: Self = Self::new(OsFamily::MacOS, 1300);       // 13.0
486    pub const MACOS_SONOMA: Self = Self::new(OsFamily::MacOS, 1400);        // 14.0
487    pub const MACOS_SEQUOIA: Self = Self::new(OsFamily::MacOS, 1500);       // 15.0
488    pub const MACOS_TAHOE: Self = Self::new(OsFamily::MacOS, 2600);         // 26.0
489    
490    // iOS versions (version_id = major * 100 + minor)
491    pub const IOS_1: Self = Self::new(OsFamily::IOS, 100);
492    pub const IOS_2: Self = Self::new(OsFamily::IOS, 200);
493    pub const IOS_3: Self = Self::new(OsFamily::IOS, 300);
494    pub const IOS_4: Self = Self::new(OsFamily::IOS, 400);
495    pub const IOS_5: Self = Self::new(OsFamily::IOS, 500);
496    pub const IOS_6: Self = Self::new(OsFamily::IOS, 600);
497    pub const IOS_7: Self = Self::new(OsFamily::IOS, 700);
498    pub const IOS_8: Self = Self::new(OsFamily::IOS, 800);
499    pub const IOS_9: Self = Self::new(OsFamily::IOS, 900);
500    pub const IOS_10: Self = Self::new(OsFamily::IOS, 1000);
501    pub const IOS_11: Self = Self::new(OsFamily::IOS, 1100);
502    pub const IOS_12: Self = Self::new(OsFamily::IOS, 1200);
503    pub const IOS_13: Self = Self::new(OsFamily::IOS, 1300);
504    pub const IOS_14: Self = Self::new(OsFamily::IOS, 1400);
505    pub const IOS_15: Self = Self::new(OsFamily::IOS, 1500);
506    pub const IOS_16: Self = Self::new(OsFamily::IOS, 1600);
507    pub const IOS_17: Self = Self::new(OsFamily::IOS, 1700);
508    pub const IOS_18: Self = Self::new(OsFamily::IOS, 1800);
509    
510    // Android versions (API level as version_id)
511    pub const ANDROID_CUPCAKE: Self = Self::new(OsFamily::Android, 3);      // 1.5
512    pub const ANDROID_DONUT: Self = Self::new(OsFamily::Android, 4);        // 1.6
513    pub const ANDROID_ECLAIR: Self = Self::new(OsFamily::Android, 7);       // 2.1
514    pub const ANDROID_FROYO: Self = Self::new(OsFamily::Android, 8);        // 2.2
515    pub const ANDROID_GINGERBREAD: Self = Self::new(OsFamily::Android, 10); // 2.3
516    pub const ANDROID_HONEYCOMB: Self = Self::new(OsFamily::Android, 13);   // 3.2
517    pub const ANDROID_ICE_CREAM_SANDWICH: Self = Self::new(OsFamily::Android, 15); // 4.0
518    pub const ANDROID_JELLY_BEAN: Self = Self::new(OsFamily::Android, 18);  // 4.3
519    pub const ANDROID_KITKAT: Self = Self::new(OsFamily::Android, 19);      // 4.4
520    pub const ANDROID_LOLLIPOP: Self = Self::new(OsFamily::Android, 22);    // 5.1
521    pub const ANDROID_MARSHMALLOW: Self = Self::new(OsFamily::Android, 23); // 6.0
522    pub const ANDROID_NOUGAT: Self = Self::new(OsFamily::Android, 25);      // 7.1
523    pub const ANDROID_OREO: Self = Self::new(OsFamily::Android, 27);        // 8.1
524    pub const ANDROID_PIE: Self = Self::new(OsFamily::Android, 28);         // 9.0
525    pub const ANDROID_10: Self = Self::new(OsFamily::Android, 29);          // 10
526    pub const ANDROID_11: Self = Self::new(OsFamily::Android, 30);          // 11
527    pub const ANDROID_12: Self = Self::new(OsFamily::Android, 31);          // 12
528    pub const ANDROID_12L: Self = Self::new(OsFamily::Android, 32);         // 12L
529    pub const ANDROID_13: Self = Self::new(OsFamily::Android, 33);          // 13
530    pub const ANDROID_14: Self = Self::new(OsFamily::Android, 34);          // 14
531    pub const ANDROID_15: Self = Self::new(OsFamily::Android, 35);          // 15
532    
533    // Linux kernel versions (major * 1000 + minor * 10 + patch)
534    pub const LINUX_2_6: Self = Self::new(OsFamily::Linux, 2060);
535    pub const LINUX_3_0: Self = Self::new(OsFamily::Linux, 3000);
536    pub const LINUX_4_0: Self = Self::new(OsFamily::Linux, 4000);
537    pub const LINUX_5_0: Self = Self::new(OsFamily::Linux, 5000);
538    pub const LINUX_6_0: Self = Self::new(OsFamily::Linux, 6000);
539    
540    /// Unknown OS version (for when detection fails or OS is unknown)
541    #[must_use] pub const fn unknown() -> Self {
542        Self {
543            os: OsFamily::Linux, // Fallback, but version_id 0 means "unknown"
544            version_id: 0,
545        }
546    }
547}
548
549/// Parse a named or numeric OS version string
550/// Returns None if the version string is not recognized
551#[must_use] pub fn parse_os_version(os: OsFamily, version_str: &str) -> Option<OsVersion> {
552    let version_str = version_str.trim().to_lowercase();
553    let version_str = version_str.as_str();
554    
555    match os {
556        OsFamily::Windows => parse_windows_version(version_str),
557        OsFamily::MacOS => parse_macos_version(version_str),
558        OsFamily::IOS => parse_ios_version(version_str),
559        OsFamily::Android => parse_android_version(version_str),
560        OsFamily::Linux => parse_linux_version(version_str),
561    }
562}
563
564fn parse_windows_version(s: &str) -> Option<OsVersion> {
565    // Strip optional "win"/"windows" prefix (allowing -, _ separators).
566    // This collapses "11", "win11", "win-11", "windows11", "windows-11", "windows_11" to "11".
567    let core = strip_os_prefix(s, &["windows", "win"]);
568    match core {
569        // Each version groups its named alias with the numeric NT version.
570        "2000" | "5.0" | "nt5.0" => Some(OsVersion::WIN_2000),
571        "xp" | "5.1" | "nt5.1" => Some(OsVersion::WIN_XP),
572        "vista" | "6.0" | "nt6.0" => Some(OsVersion::WIN_VISTA),
573        "7" | "6.1" | "nt6.1" => Some(OsVersion::WIN_7),
574        "8" | "6.2" | "nt6.2" => Some(OsVersion::WIN_8),
575        "8.1" | "8-1" | "6.3" | "nt6.3" => Some(OsVersion::WIN_8_1),
576        "10" | "10.0" | "nt10.0" => Some(OsVersion::WIN_10),
577        "11" => Some(OsVersion::WIN_11),
578        _ => None,
579    }
580}
581
582/// If `s` starts with any of the given prefixes, strip the prefix plus an optional
583/// trailing `-` or `_` separator. Otherwise return `s` unchanged. Matching is
584/// case-insensitive (callers already lowercase, this just makes the helper safe).
585fn strip_os_prefix<'a>(s: &'a str, prefixes: &[&str]) -> &'a str {
586    for p in prefixes {
587        if let Some(rest) = s.strip_prefix(p) {
588            return rest.strip_prefix(['-', '_']).unwrap_or(rest);
589        }
590    }
591    s
592}
593
594fn parse_macos_version(s: &str) -> Option<OsVersion> {
595    match s {
596        "cheetah" | "10.0" => Some(OsVersion::MACOS_CHEETAH),
597        "puma" | "10.1" => Some(OsVersion::MACOS_PUMA),
598        "jaguar" | "10.2" => Some(OsVersion::MACOS_JAGUAR),
599        "panther" | "10.3" => Some(OsVersion::MACOS_PANTHER),
600        "tiger" | "10.4" => Some(OsVersion::MACOS_TIGER),
601        "leopard" | "10.5" => Some(OsVersion::MACOS_LEOPARD),
602        "snow-leopard" | "snowleopard" | "10.6" => Some(OsVersion::MACOS_SNOW_LEOPARD),
603        "lion" | "10.7" => Some(OsVersion::MACOS_LION),
604        "mountain-lion" | "mountainlion" | "10.8" => Some(OsVersion::MACOS_MOUNTAIN_LION),
605        "mavericks" | "10.9" => Some(OsVersion::MACOS_MAVERICKS),
606        "yosemite" | "10.10" => Some(OsVersion::MACOS_YOSEMITE),
607        "el-capitan" | "elcapitan" | "10.11" => Some(OsVersion::MACOS_EL_CAPITAN),
608        "sierra" | "10.12" => Some(OsVersion::MACOS_SIERRA),
609        "high-sierra" | "highsierra" | "10.13" => Some(OsVersion::MACOS_HIGH_SIERRA),
610        "mojave" | "10.14" => Some(OsVersion::MACOS_MOJAVE),
611        "catalina" | "10.15" => Some(OsVersion::MACOS_CATALINA),
612        "big-sur" | "bigsur" | "11" | "11.0" => Some(OsVersion::MACOS_BIG_SUR),
613        "monterey" | "12" | "12.0" => Some(OsVersion::MACOS_MONTEREY),
614        "ventura" | "13" | "13.0" => Some(OsVersion::MACOS_VENTURA),
615        "sonoma" | "14" | "14.0" => Some(OsVersion::MACOS_SONOMA),
616        "sequoia" | "15" | "15.0" => Some(OsVersion::MACOS_SEQUOIA),
617        "tahoe" | "26" | "26.0" => Some(OsVersion::MACOS_TAHOE),
618        _ => None,
619    }
620}
621
622fn parse_ios_version(s: &str) -> Option<OsVersion> {
623    match s {
624        "1" | "1.0" => Some(OsVersion::IOS_1),
625        "2" | "2.0" => Some(OsVersion::IOS_2),
626        "3" | "3.0" => Some(OsVersion::IOS_3),
627        "4" | "4.0" => Some(OsVersion::IOS_4),
628        "5" | "5.0" => Some(OsVersion::IOS_5),
629        "6" | "6.0" => Some(OsVersion::IOS_6),
630        "7" | "7.0" => Some(OsVersion::IOS_7),
631        "8" | "8.0" => Some(OsVersion::IOS_8),
632        "9" | "9.0" => Some(OsVersion::IOS_9),
633        "10" | "10.0" => Some(OsVersion::IOS_10),
634        "11" | "11.0" => Some(OsVersion::IOS_11),
635        "12" | "12.0" => Some(OsVersion::IOS_12),
636        "13" | "13.0" => Some(OsVersion::IOS_13),
637        "14" | "14.0" => Some(OsVersion::IOS_14),
638        "15" | "15.0" => Some(OsVersion::IOS_15),
639        "16" | "16.0" => Some(OsVersion::IOS_16),
640        "17" | "17.0" => Some(OsVersion::IOS_17),
641        "18" | "18.0" => Some(OsVersion::IOS_18),
642        _ => None,
643    }
644}
645
646fn parse_android_version(s: &str) -> Option<OsVersion> {
647    match s {
648        "cupcake" | "1.5" => Some(OsVersion::ANDROID_CUPCAKE),
649        "donut" | "1.6" => Some(OsVersion::ANDROID_DONUT),
650        "eclair" | "2.1" => Some(OsVersion::ANDROID_ECLAIR),
651        "froyo" | "2.2" => Some(OsVersion::ANDROID_FROYO),
652        "gingerbread" | "2.3" => Some(OsVersion::ANDROID_GINGERBREAD),
653        "honeycomb" | "3.0" | "3.2" => Some(OsVersion::ANDROID_HONEYCOMB),
654        "ice-cream-sandwich" | "ics" | "4.0" => Some(OsVersion::ANDROID_ICE_CREAM_SANDWICH),
655        "jelly-bean" | "jellybean" | "4.3" => Some(OsVersion::ANDROID_JELLY_BEAN),
656        "kitkat" | "4.4" => Some(OsVersion::ANDROID_KITKAT),
657        "lollipop" | "5.0" | "5.1" => Some(OsVersion::ANDROID_LOLLIPOP),
658        "marshmallow" | "6.0" => Some(OsVersion::ANDROID_MARSHMALLOW),
659        "nougat" | "7.0" | "7.1" => Some(OsVersion::ANDROID_NOUGAT),
660        "oreo" | "8.0" | "8.1" => Some(OsVersion::ANDROID_OREO),
661        "pie" | "9" | "9.0" => Some(OsVersion::ANDROID_PIE),
662        "10" | "q" => Some(OsVersion::ANDROID_10),
663        "11" | "r" => Some(OsVersion::ANDROID_11),
664        "12" | "s" => Some(OsVersion::ANDROID_12),
665        "12l" | "12L" => Some(OsVersion::ANDROID_12L),
666        "13" | "t" | "tiramisu" => Some(OsVersion::ANDROID_13),
667        "14" | "u" | "upside-down-cake" => Some(OsVersion::ANDROID_14),
668        "15" | "v" | "vanilla-ice-cream" => Some(OsVersion::ANDROID_15),
669        _ => {
670            // Try parsing as API level
671            if let Some(api) = s.strip_prefix("api") {
672                if let Ok(level) = api.trim().parse::<u32>() {
673                    return Some(OsVersion::new(OsFamily::Android, level));
674                }
675            }
676            None
677        }
678    }
679}
680
681fn parse_linux_version(s: &str) -> Option<OsVersion> {
682    // Strip optional "linux" prefix so "linux6.1" / "linux-6.1" also work.
683    let s = strip_os_prefix(s, &["linux"]);
684    // Parse kernel version like "5.4", "6.0", or bare major like "5" (== "5.0").
685    let mut parts = s.split('.');
686    let major = parts.next()?.parse::<u32>().ok()?;
687    let minor = parts.next().map_or(Some(0), |p| p.parse::<u32>().ok())?;
688    let patch = parts.next().map_or(Some(0), |p| p.parse::<u32>().ok())?;
689    Some(OsVersion::new(OsFamily::Linux, major * 1000 + minor * 10 + patch))
690}
691
692/// Linux desktop environment for `@os(linux:<de>)` CSS selectors.
693///
694/// Note: `from_system_desktop_env` currently only maps Gnome, KDE, and Other.
695/// XFCE, Unity, Cinnamon, and MATE can be matched via CSS parsing (`@os(linux:xfce)`)
696/// but will not be auto-detected from the system — they map to `Other` at runtime.
697#[repr(C)]
698#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
699pub enum LinuxDesktopEnv {
700    Gnome,
701    KDE,
702    /// CSS-parse-only: not auto-detected from system (maps to `Other` at runtime)
703    XFCE,
704    /// CSS-parse-only: not auto-detected from system (maps to `Other` at runtime)
705    Unity,
706    /// CSS-parse-only: not auto-detected from system (maps to `Other` at runtime)
707    Cinnamon,
708    /// CSS-parse-only: not auto-detected from system (maps to `Other` at runtime)
709    MATE,
710    Other,
711}
712
713impl LinuxDesktopEnv {
714    /// Convert from `css::system::DesktopEnvironment`
715    #[must_use] pub const fn from_system_desktop_env(de: &crate::system::DesktopEnvironment) -> Self {
716        use crate::system::DesktopEnvironment;
717        match de {
718            DesktopEnvironment::Gnome => Self::Gnome,
719            DesktopEnvironment::Kde => Self::KDE,
720            DesktopEnvironment::Other(_) => Self::Other,
721        }
722    }
723}
724
725/// Media type for `@media` CSS selectors (screen, print, all)
726#[repr(C)]
727#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
728pub enum MediaType {
729    Screen,
730    Print,
731    All,
732}
733#[allow(variant_size_differences)] // repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size disparity accepted
734#[repr(C, u8)]
735#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
736pub enum ThemeCondition {
737    Light,
738    Dark,
739    Custom(AzString),
740    /// System preference
741    SystemPreferred,
742}
743
744impl_option!(
745    ThemeCondition,
746    OptionThemeCondition,
747    copy = false,
748    [Debug, Clone, PartialEq, Eq, Hash]
749);
750
751impl ThemeCondition {
752    /// Convert from `css::system::Theme`
753    #[must_use] pub const fn from_system_theme(theme: crate::system::Theme) -> Self {
754        use crate::system::Theme;
755        match theme {
756            Theme::Light => Self::Light,
757            Theme::Dark => Self::Dark,
758        }
759    }
760}
761
762/// Orientation type for `@media (orientation: ...)` CSS selectors
763#[repr(C)]
764#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
765pub enum OrientationType {
766    Portrait,
767    Landscape,
768}
769
770/// Language/Locale condition for @`lang()` CSS selector
771/// Matches BCP 47 language tags with prefix matching
772#[repr(C, u8)]
773#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
774pub enum LanguageCondition {
775    /// Exact match (e.g., "de-DE" matches only "de-DE")
776    Exact(AzString),
777    /// Prefix match (e.g., "de" matches "de", "de-DE", "de-AT", etc.)
778    Prefix(AzString),
779}
780
781impl LanguageCondition {
782    /// Check if this condition matches the given language tag
783    #[must_use] pub fn matches(&self, language: &str) -> bool {
784        match self {
785            Self::Exact(lang) => language.eq_ignore_ascii_case(lang.as_str()),
786            Self::Prefix(prefix) => {
787                let prefix_str = prefix.as_str();
788                if language.len() < prefix_str.len() {
789                    return false;
790                }
791                // Check if language starts with prefix (case-insensitive)
792                let lang_prefix = &language[..prefix_str.len()];
793                if !lang_prefix.eq_ignore_ascii_case(prefix_str) {
794                    return false;
795                }
796                // Must be exact match or followed by '-'
797                language.len() == prefix_str.len()
798                    || language.as_bytes().get(prefix_str.len()) == Some(&b'-')
799            }
800        }
801    }
802}
803
804#[repr(C)]
805#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
806pub enum PseudoStateType {
807    /// No special state (corresponds to "Normal" in `NodeDataInlineCssProperty`)
808    Normal,
809    /// Element is being hovered (:hover)
810    Hover,
811    /// Element is active/being clicked (:active)
812    Active,
813    /// Element has focus (:focus)
814    Focus,
815    /// Element is disabled (:disabled)
816    Disabled,
817    /// Element is checked/selected (:checked)
818    CheckedTrue,
819    /// Element is unchecked (:not(:checked))
820    CheckedFalse,
821    /// Element or child has focus (:focus-within)
822    FocusWithin,
823    /// Link has been visited (:visited)
824    Visited,
825    /// Window is not focused (:backdrop) - GTK compatibility
826    Backdrop,
827    /// Element is currently being dragged (:dragging)
828    Dragging,
829    /// A dragged element is over this drop target (:drag-over)
830    DragOver,
831}
832
833impl_option!(
834    LinuxDesktopEnv,
835    OptionLinuxDesktopEnv,
836    [Debug, Clone, Copy, PartialEq, Eq, Hash]
837);
838
839/// Default viewport width used when actual window size is not yet known.
840pub const DEFAULT_VIEWPORT_WIDTH: f32 = 800.0;
841/// Default viewport height used when actual window size is not yet known.
842pub const DEFAULT_VIEWPORT_HEIGHT: f32 = 600.0;
843
844/// Context for evaluating dynamic selectors
845#[repr(C)]
846#[derive(Debug, Clone)]
847pub struct DynamicSelectorContext {
848    /// Operating system info
849    pub os: OsCondition,
850    pub os_version: OsVersion,
851    pub desktop_env: OptionLinuxDesktopEnv,
852    /// Numeric version of the active desktop environment (0 = unknown).
853    /// Used by `@os(linux:gnome > 40)` style selectors. A value of 0 never
854    /// satisfies any DE-version constraint, so detection can be wired up
855    /// later without breaking parsed rules.
856    pub de_version: u32,
857
858    /// Theme info
859    pub theme: ThemeCondition,
860
861    /// Media info (from `WindowState`)
862    pub media_type: MediaType,
863    pub viewport_width: f32,
864    pub viewport_height: f32,
865
866    /// Container info (from parent node)
867    /// NaN = no container
868    pub container_width: f32,
869    pub container_height: f32,
870    pub container_name: OptionString,
871
872    /// Accessibility preferences
873    pub prefers_reduced_motion: BoolCondition,
874    pub prefers_high_contrast: BoolCondition,
875
876    /// Orientation
877    pub orientation: OrientationType,
878
879    /// Node state (hover, active, focus, disabled, checked, `focus_within`, visited)
880    pub pseudo_state: PseudoStateFlags,
881
882    /// Language/Locale (BCP 47 tag, e.g., "en-US", "de-DE")
883    pub language: AzString,
884
885    /// Whether the window currently has focus (for :backdrop pseudo-class)
886    /// When false, :backdrop styles should be applied
887    pub window_focused: bool,
888}
889
890impl Default for DynamicSelectorContext {
891    fn default() -> Self {
892        Self {
893            os: OsCondition::Any,
894            os_version: OsVersion::unknown(),
895            desktop_env: OptionLinuxDesktopEnv::None,
896            de_version: 0,
897            theme: ThemeCondition::Light,
898            media_type: MediaType::Screen,
899            viewport_width: DEFAULT_VIEWPORT_WIDTH,
900            viewport_height: DEFAULT_VIEWPORT_HEIGHT,
901            container_width: f32::NAN,
902            container_height: f32::NAN,
903            container_name: OptionString::None,
904            prefers_reduced_motion: BoolCondition::False,
905            prefers_high_contrast: BoolCondition::False,
906            orientation: OrientationType::Landscape,
907            pseudo_state: PseudoStateFlags::default(),
908            language: AzString::from_const_str("en-US"),
909            window_focused: true,
910        }
911    }
912}
913
914impl DynamicSelectorContext {
915    /// Create a context from `SystemStyle`
916    #[must_use] pub fn from_system_style(system_style: &crate::system::SystemStyle) -> Self {
917        let os = OsCondition::from_system_platform(&system_style.platform);
918        let desktop_env = if let crate::system::Platform::Linux(de) = &system_style.platform {
919            OptionLinuxDesktopEnv::Some(LinuxDesktopEnv::from_system_desktop_env(de))
920        } else {
921            OptionLinuxDesktopEnv::None
922        };
923        let theme = ThemeCondition::from_system_theme(system_style.theme);
924
925        Self {
926            os,
927            os_version: system_style.os_version, // Use version from SystemStyle
928            desktop_env,
929            de_version: 0, // TODO: wire up DE version detection in system::detect_*
930            theme,
931            media_type: MediaType::Screen,
932            viewport_width: DEFAULT_VIEWPORT_WIDTH, // Will be updated with window size
933            viewport_height: DEFAULT_VIEWPORT_HEIGHT,
934            container_width: f32::NAN,
935            container_height: f32::NAN,
936            container_name: OptionString::None,
937            prefers_reduced_motion: system_style.prefers_reduced_motion,
938            prefers_high_contrast: system_style.prefers_high_contrast,
939            orientation: OrientationType::Landscape,
940            pseudo_state: PseudoStateFlags::default(),
941            language: system_style.language.clone(),
942            window_focused: true,
943        }
944    }
945
946    /// Update viewport dimensions (e.g., on window resize)
947    #[must_use] pub fn with_viewport(&self, width: f32, height: f32) -> Self {
948        let mut ctx = self.clone();
949        ctx.viewport_width = width;
950        ctx.viewport_height = height;
951        ctx.orientation = if width > height {
952            OrientationType::Landscape
953        } else {
954            OrientationType::Portrait
955        };
956        ctx
957    }
958
959    /// Update container dimensions (for @container queries)
960    #[must_use] pub fn with_container(&self, width: f32, height: f32, name: Option<AzString>) -> Self {
961        let mut ctx = self.clone();
962        ctx.container_width = width;
963        ctx.container_height = height;
964        ctx.container_name = name.into();
965        ctx
966    }
967
968    /// Update pseudo-state (hover, active, focus, etc.)
969    #[must_use] pub fn with_pseudo_state(&self, state: PseudoStateFlags) -> Self {
970        let mut ctx = self.clone();
971        ctx.pseudo_state = state;
972        ctx
973    }
974
975    /// Check if viewport changed significantly (for breakpoint detection)
976    #[must_use] pub fn viewport_breakpoint_changed(&self, other: &Self, breakpoints: &[f32]) -> bool {
977        for bp in breakpoints {
978            let self_above = self.viewport_width >= *bp;
979            let other_above = other.viewport_width >= *bp;
980            if self_above != other_above {
981                return true;
982            }
983        }
984        false
985    }
986}
987
988impl DynamicSelector {
989    /// Check if this selector matches in the given context
990    #[must_use] pub fn matches(&self, ctx: &DynamicSelectorContext) -> bool {
991        match self {
992            Self::Os(os) => Self::match_os(*os, ctx.os),
993            Self::OsVersion(ver) => Self::match_os_version(ver, ctx.os_version, ctx.desktop_env, ctx.de_version),
994            Self::Media(media) => *media == ctx.media_type || *media == MediaType::All,
995            Self::ViewportWidth(range) => range.matches(ctx.viewport_width),
996            Self::ViewportHeight(range) => range.matches(ctx.viewport_height),
997            Self::ContainerWidth(range) => {
998                !ctx.container_width.is_nan() && range.matches(ctx.container_width)
999            }
1000            Self::ContainerHeight(range) => {
1001                !ctx.container_height.is_nan() && range.matches(ctx.container_height)
1002            }
1003            Self::ContainerName(name) => ctx.container_name.as_ref() == Some(name),
1004            Self::Theme(theme) => Self::match_theme(theme, &ctx.theme),
1005            Self::AspectRatio(range) => {
1006                let ratio = ctx.viewport_width / ctx.viewport_height.max(1.0);
1007                range.matches(ratio)
1008            }
1009            Self::Orientation(orient) => *orient == ctx.orientation,
1010            Self::PrefersReducedMotion(pref) => {
1011                bool::from(*pref) == bool::from(ctx.prefers_reduced_motion)
1012            }
1013            Self::PrefersHighContrast(pref) => {
1014                bool::from(*pref) == bool::from(ctx.prefers_high_contrast)
1015            }
1016            Self::PseudoState(state) => Self::match_pseudo_state(*state, ctx),
1017            Self::Language(lang_cond) => lang_cond.matches(ctx.language.as_str()),
1018        }
1019    }
1020
1021    fn match_os(condition: OsCondition, actual: OsCondition) -> bool {
1022        match condition {
1023            OsCondition::Any => true,
1024            OsCondition::Apple => matches!(actual, OsCondition::MacOS | OsCondition::IOS),
1025            _ => condition == actual,
1026        }
1027    }
1028
1029    fn match_os_version(
1030        condition: &OsVersionCondition,
1031        actual: OsVersion,
1032        desktop_env: OptionLinuxDesktopEnv,
1033        de_version: u32,
1034    ) -> bool {
1035        // de_version == 0 means the runtime hasn't reported a version,
1036        // so any DE-version constraint fails until detection is wired up.
1037        let de_matches = |env: &LinuxDesktopEnv| desktop_env.as_ref() == Some(env);
1038        match condition {
1039            OsVersionCondition::Exact(ver) => actual.is_exactly(ver),
1040            OsVersionCondition::Min(ver) => actual.is_at_least(ver),
1041            OsVersionCondition::Max(ver) => actual.is_at_most(ver),
1042            OsVersionCondition::DesktopEnvironment(env) => de_matches(env),
1043            OsVersionCondition::DesktopEnvMin(d) =>
1044                de_matches(&d.env) && de_version != 0 && de_version >= d.version_id,
1045            OsVersionCondition::DesktopEnvMax(d) =>
1046                de_matches(&d.env) && de_version != 0 && de_version <= d.version_id,
1047            OsVersionCondition::DesktopEnvExact(d) =>
1048                de_matches(&d.env) && de_version == d.version_id,
1049        }
1050    }
1051
1052    fn match_theme(condition: &ThemeCondition, actual: &ThemeCondition) -> bool {
1053        match (condition, actual) {
1054            (ThemeCondition::SystemPreferred, _) => true,
1055            _ => condition == actual,
1056        }
1057    }
1058
1059    const fn match_pseudo_state(state: PseudoStateType, ctx: &DynamicSelectorContext) -> bool {
1060        let node_state = &ctx.pseudo_state;
1061        match state {
1062            PseudoStateType::Normal => true, // Normal is always active (base state)
1063            PseudoStateType::Hover => node_state.hover,
1064            PseudoStateType::Active => node_state.active,
1065            PseudoStateType::Focus => node_state.focused,
1066            PseudoStateType::Disabled => node_state.disabled,
1067            PseudoStateType::CheckedTrue => node_state.checked,
1068            PseudoStateType::CheckedFalse => !node_state.checked,
1069            PseudoStateType::FocusWithin => node_state.focus_within,
1070            PseudoStateType::Visited => node_state.visited,
1071            PseudoStateType::Backdrop => node_state.backdrop,
1072            PseudoStateType::Dragging => node_state.dragging,
1073            PseudoStateType::DragOver => node_state.drag_over,
1074        }
1075    }
1076}
1077
1078/// Parse the content of an `@os(...)` at-rule into a list of dynamic-selector conditions.
1079///
1080/// Accepts both bare-identifier and parenthesized forms:
1081///
1082/// - `linux`                       → `[Os(Linux)]`
1083/// - `(linux)`                     → `[Os(Linux)]`
1084/// - `(linux:gnome)`               → `[Os(Linux), OsVersion(DesktopEnvironment(Gnome))]`
1085/// - `(windows >= win-11)`         → `[Os(Windows), OsVersion(Min(WIN_11))]`
1086/// - `(linux:gnome > 40)`          → `[Os(Linux), OsVersion(DesktopEnvMin{ env: Gnome, version_id: 40 })]`
1087/// - `(any)` / `(*)` / `(all)`     → `[]` (always-match, no conditions emitted)
1088///
1089/// Returns `None` only when the content is a parse error.
1090/// `Some(vec![])` means "always match" (the rule applies unconditionally).
1091#[cfg(feature = "parser")]
1092#[must_use] pub fn parse_os_at_rule_content(content: &str) -> Option<Vec<DynamicSelector>> {
1093    let trimmed = content.trim();
1094    let inner = trimmed
1095        .strip_prefix('(').and_then(|s| s.strip_suffix(')'))
1096        .unwrap_or(trimmed)
1097        .trim();
1098    let inner = inner
1099        .strip_prefix('"').and_then(|s| s.strip_suffix('"'))
1100        .or_else(|| inner.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
1101        .unwrap_or(inner)
1102        .trim();
1103    if inner.is_empty() {
1104        return None;
1105    }
1106
1107    // Split off the operator + version, if any.
1108    let (subject, op_and_version) = split_op_and_version(inner);
1109    let subject = subject.trim();
1110
1111    // subject is "family" or "family:de"
1112    let (family_str, de_str) = match subject.split_once(':') {
1113        Some((f, d)) => (f.trim(), Some(d.trim())),
1114        None => (subject, None),
1115    };
1116
1117    let family = parse_os_family_token(family_str)?;
1118    let de = match de_str {
1119        Some(s) if !s.is_empty() => Some(parse_de_token(s)),
1120        _ => None,
1121    };
1122
1123    let mut out = Vec::new();
1124    // Always emit the family selector, even for `Any` — `Os(Any)` is matched as
1125    // unconditionally true, but keeping it in the conditions list makes the rule
1126    // structure visible to introspection.
1127    out.push(DynamicSelector::Os(family));
1128
1129    match (de, op_and_version) {
1130        // Bare DE with no version: just "is the DE this one"
1131        (Some(env), None) => {
1132            out.push(DynamicSelector::OsVersion(OsVersionCondition::DesktopEnvironment(env)));
1133        }
1134        // DE + version: emit a DesktopEnv* condition
1135        (Some(env), Some((op, ver_str))) => {
1136            let v: u32 = ver_str.parse().ok()?;
1137            let dev = DesktopEnvVersion { env, version_id: v };
1138            let cond = match op {
1139                VersionOp::Min => OsVersionCondition::DesktopEnvMin(dev),
1140                VersionOp::Max => OsVersionCondition::DesktopEnvMax(dev),
1141                VersionOp::Exact => OsVersionCondition::DesktopEnvExact(dev),
1142            };
1143            out.push(DynamicSelector::OsVersion(cond));
1144        }
1145        // OS family + version
1146        (None, Some((op, ver_str))) => {
1147            let os_family = match family {
1148                OsCondition::Linux => OsFamily::Linux,
1149                OsCondition::Windows => OsFamily::Windows,
1150                OsCondition::MacOS => OsFamily::MacOS,
1151                OsCondition::IOS => OsFamily::IOS,
1152                OsCondition::Android => OsFamily::Android,
1153                // Apple, Web, Any have no version line — reject.
1154                _ => return None,
1155            };
1156            let version = parse_os_version(os_family, ver_str)?;
1157            let cond = match op {
1158                VersionOp::Min => OsVersionCondition::Min(version),
1159                VersionOp::Max => OsVersionCondition::Max(version),
1160                VersionOp::Exact => OsVersionCondition::Exact(version),
1161            };
1162            out.push(DynamicSelector::OsVersion(cond));
1163        }
1164        // Family only — already pushed above (or empty for `any`).
1165        (None, None) => {}
1166    }
1167
1168    Some(out)
1169}
1170
1171#[cfg(feature = "parser")]
1172#[derive(Copy, Clone)]
1173enum VersionOp { Min, Max, Exact }
1174
1175/// Find the first comparison operator (`>=`, `<=`, `=`, `>`, `<`) in `s` and split.
1176/// `>` and `<` are treated as `>=` / `<=` because version IDs are discrete integers.
1177#[cfg(feature = "parser")]
1178fn split_op_and_version(s: &str) -> (&str, Option<(VersionOp, &str)>) {
1179    // Earliest match wins; on a tie, the longer operator wins (so ">=" beats "=" at the same position).
1180    let candidates: &[(&str, VersionOp)] = &[
1181        (">=", VersionOp::Min),
1182        ("<=", VersionOp::Max),
1183        ("=",  VersionOp::Exact),
1184        (">",  VersionOp::Min),
1185        ("<",  VersionOp::Max),
1186    ];
1187    let mut best: Option<(usize, usize, VersionOp)> = None;
1188    for (op_str, op) in candidates {
1189        if let Some(pos) = s.find(op_str) {
1190            let len = op_str.len();
1191            best = Some(match best {
1192                None => (pos, len, *op),
1193                Some((bp, bl, _)) if pos < bp || (pos == bp && len > bl) => (pos, len, *op),
1194                Some(b) => b,
1195            });
1196        }
1197    }
1198    match best {
1199        Some((pos, len, op)) => (&s[..pos], Some((op, s[pos + len..].trim()))),
1200        None => (s, None),
1201    }
1202}
1203
1204#[cfg(feature = "parser")]
1205fn parse_os_family_token(s: &str) -> Option<OsCondition> {
1206    match s.to_lowercase().as_str() {
1207        "linux" => Some(OsCondition::Linux),
1208        "windows" | "win" => Some(OsCondition::Windows),
1209        "macos" | "mac" | "osx" => Some(OsCondition::MacOS),
1210        "ios" => Some(OsCondition::IOS),
1211        "android" => Some(OsCondition::Android),
1212        "apple" => Some(OsCondition::Apple),
1213        "web" | "wasm" => Some(OsCondition::Web),
1214        "any" | "all" | "*" => Some(OsCondition::Any),
1215        _ => None,
1216    }
1217}
1218
1219#[cfg(feature = "parser")]
1220fn parse_de_token(s: &str) -> LinuxDesktopEnv {
1221    match s.to_lowercase().as_str() {
1222        "gnome" => LinuxDesktopEnv::Gnome,
1223        "kde" => LinuxDesktopEnv::KDE,
1224        "xfce" => LinuxDesktopEnv::XFCE,
1225        "unity" => LinuxDesktopEnv::Unity,
1226        "cinnamon" => LinuxDesktopEnv::Cinnamon,
1227        "mate" => LinuxDesktopEnv::MATE,
1228        _ => LinuxDesktopEnv::Other,
1229    }
1230}
1231
1232// ============================================================================
1233// CssPropertyWithConditions - Replacement for NodeDataInlineCssProperty
1234// ============================================================================
1235
1236/// A CSS property with optional conditions for when it should be applied.
1237/// This replaces `NodeDataInlineCssProperty` with a more flexible system.
1238///
1239/// If `apply_if` is empty, the property always applies.
1240/// If `apply_if` contains conditions, ALL conditions must be satisfied for the property to apply.
1241#[repr(C)]
1242#[derive(Debug, Clone, PartialEq)]
1243pub struct CssPropertyWithConditions {
1244    /// The actual CSS property value
1245    pub property: CssProperty,
1246    /// Conditions that must all be satisfied for this property to apply.
1247    /// Empty means unconditional (always apply).
1248    pub apply_if: DynamicSelectorVec,
1249}
1250
1251impl_option!(
1252    CssPropertyWithConditions,
1253    OptionCssPropertyWithConditions,
1254    copy = false,
1255    [Debug, Clone, PartialEq, Eq, PartialOrd]
1256);
1257
1258impl Eq for CssPropertyWithConditions {}
1259
1260impl PartialOrd for CssPropertyWithConditions {
1261    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
1262        Some(self.cmp(other))
1263    }
1264}
1265
1266impl Ord for CssPropertyWithConditions {
1267    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
1268        // Order by the property first, then lexicographically by the full list of
1269        // conditions. This is consistent with the derived `PartialEq` (which compares
1270        // both fields) and with `Hash` below, so the type is sound to use as a
1271        // `BTreeMap`/`BTreeSet` key or to dedup after sorting. (The previous impl
1272        // compared the condition *count* only, which violated the Eq/Ord agreement.)
1273        self.property
1274            .cmp(&other.property)
1275            .then_with(|| self.apply_if.as_slice().cmp(other.apply_if.as_slice()))
1276    }
1277}
1278
1279impl CssPropertyWithConditions {
1280    /// Create an unconditional property (always applies) - const version
1281    #[must_use] pub const fn simple(property: CssProperty) -> Self {
1282        Self {
1283            property,
1284            apply_if: DynamicSelectorVec::from_const_slice(&[]),
1285        }
1286    }
1287
1288    /// Create a property with a single condition (const version using slice reference)
1289    #[must_use] pub const fn with_single_condition(
1290        property: CssProperty,
1291        conditions: &'static [DynamicSelector],
1292    ) -> Self {
1293        Self {
1294            property,
1295            apply_if: DynamicSelectorVec::from_const_slice(conditions),
1296        }
1297    }
1298
1299    /// Create a property with a single condition (non-const, allocates)
1300    #[must_use] pub fn with_condition(property: CssProperty, condition: DynamicSelector) -> Self {
1301        Self {
1302            property,
1303            apply_if: DynamicSelectorVec::from_vec(vec![condition]),
1304        }
1305    }
1306
1307    /// Create a property with multiple conditions (all must match)
1308    #[must_use] pub const fn with_conditions(property: CssProperty, conditions: DynamicSelectorVec) -> Self {
1309        Self {
1310            property,
1311            apply_if: conditions,
1312        }
1313    }
1314
1315    /// Create a property that applies only on hover (const version)
1316    #[must_use] pub const fn on_hover(property: CssProperty) -> Self {
1317        Self::with_single_condition(
1318            property,
1319            &[DynamicSelector::PseudoState(PseudoStateType::Hover)],
1320        )
1321    }
1322
1323    /// Create a property that applies only when active (const version)
1324    #[must_use] pub const fn on_active(property: CssProperty) -> Self {
1325        Self::with_single_condition(
1326            property,
1327            &[DynamicSelector::PseudoState(PseudoStateType::Active)],
1328        )
1329    }
1330
1331    /// Create a property that applies only when focused (const version)
1332    #[must_use] pub const fn on_focus(property: CssProperty) -> Self {
1333        Self::with_single_condition(
1334            property,
1335            &[DynamicSelector::PseudoState(PseudoStateType::Focus)],
1336        )
1337    }
1338
1339    /// Create a property that applies only when disabled (const version)
1340    #[must_use] pub const fn when_disabled(property: CssProperty) -> Self {
1341        Self::with_single_condition(
1342            property,
1343            &[DynamicSelector::PseudoState(PseudoStateType::Disabled)],
1344        )
1345    }
1346
1347    /// Create a property that applies only on a specific OS (non-const, needs runtime value)
1348    #[must_use] pub fn on_os(property: CssProperty, os: OsCondition) -> Self {
1349        Self::with_condition(property, DynamicSelector::Os(os))
1350    }
1351
1352    /// Create a property that applies only in dark theme (const version)
1353    #[must_use] pub const fn dark_theme(property: CssProperty) -> Self {
1354        Self::with_single_condition(property, &[DynamicSelector::Theme(ThemeCondition::Dark)])
1355    }
1356
1357    /// Create a property that applies only in light theme (const version)
1358    #[must_use] pub const fn light_theme(property: CssProperty) -> Self {
1359        Self::with_single_condition(property, &[DynamicSelector::Theme(ThemeCondition::Light)])
1360    }
1361
1362    /// Create a property for Windows only (const version)
1363    #[must_use] pub const fn on_windows(property: CssProperty) -> Self {
1364        Self::with_single_condition(property, &[DynamicSelector::Os(OsCondition::Windows)])
1365    }
1366
1367    /// Create a property for macOS only (const version)
1368    #[must_use] pub const fn on_macos(property: CssProperty) -> Self {
1369        Self::with_single_condition(property, &[DynamicSelector::Os(OsCondition::MacOS)])
1370    }
1371
1372    /// Create a property for Linux only (const version)
1373    #[must_use] pub const fn on_linux(property: CssProperty) -> Self {
1374        Self::with_single_condition(property, &[DynamicSelector::Os(OsCondition::Linux)])
1375    }
1376
1377    /// Check if this property matches in the given context
1378    #[must_use] pub fn matches(&self, ctx: &DynamicSelectorContext) -> bool {
1379        // Empty conditions = always matches
1380        if self.apply_if.as_slice().is_empty() {
1381            return true;
1382        }
1383
1384        // All conditions must match
1385        self.apply_if
1386            .as_slice()
1387            .iter()
1388            .all(|selector| selector.matches(ctx))
1389    }
1390
1391    /// Check if this property has any conditions
1392    #[must_use] pub fn is_conditional(&self) -> bool {
1393        !self.apply_if.as_slice().is_empty()
1394    }
1395
1396    /// Check if this property is a pseudo-state conditional only
1397    /// (hover, active, focus, etc.)
1398    #[must_use] pub fn is_pseudo_state_only(&self) -> bool {
1399        let conditions = self.apply_if.as_slice();
1400        !conditions.is_empty()
1401            && conditions
1402                .iter()
1403                .all(|c| matches!(c, DynamicSelector::PseudoState(_)))
1404    }
1405
1406    /// Check if this property affects layout (width, height, margin, etc.)
1407    /// 
1408    /// Returns `true` for layout-affecting properties like width, height, margin, padding,
1409    /// font-size, etc. Returns `false` for paint-only properties like color, background,
1410    /// box-shadow, opacity, transform, etc.
1411    #[must_use] pub const fn is_layout_affecting(&self) -> bool {
1412        self.property.get_type().can_trigger_relayout()
1413    }
1414}
1415
1416impl_vec!(CssPropertyWithConditions, CssPropertyWithConditionsVec, CssPropertyWithConditionsVecDestructor, CssPropertyWithConditionsVecDestructorType, CssPropertyWithConditionsVecSlice, OptionCssPropertyWithConditions);
1417impl_vec_debug!(CssPropertyWithConditions, CssPropertyWithConditionsVec);
1418impl_vec_partialeq!(CssPropertyWithConditions, CssPropertyWithConditionsVec);
1419impl_vec_partialord!(CssPropertyWithConditions, CssPropertyWithConditionsVec);
1420impl_vec_clone!(
1421    CssPropertyWithConditions,
1422    CssPropertyWithConditionsVec,
1423    CssPropertyWithConditionsVecDestructor
1424);
1425
1426// Manual implementations for Eq and Ord (required for NodeData derives)
1427impl Eq for CssPropertyWithConditionsVec {}
1428
1429impl Ord for CssPropertyWithConditionsVec {
1430    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
1431        // Lexicographic, matching the `impl_vec_partialord!` PartialOrd above and the
1432        // element `Ord`; previously this compared length only (inconsistent with Eq).
1433        self.as_slice().cmp(other.as_slice())
1434    }
1435}
1436
1437impl core::hash::Hash for CssPropertyWithConditions {
1438    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
1439        self.property.hash(state);
1440        // Hash the full set of conditions (length + each selector, via the now-`Hash`
1441        // `DynamicSelector`) so the hash agrees with `Eq`/`Ord` instead of colliding
1442        // on condition count alone.
1443        self.apply_if.as_slice().hash(state);
1444    }
1445}
1446
1447impl core::hash::Hash for CssPropertyWithConditionsVec {
1448    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
1449        // Hashing the slice folds in the length as well as every element.
1450        self.as_slice().hash(state);
1451    }
1452}
1453
1454impl CssPropertyWithConditionsVec {
1455    /// Parse CSS with support for selectors and nesting.
1456    /// 
1457    /// Supports:
1458    /// - Simple properties: `color: red;`
1459    /// - Pseudo-selectors: `:hover { background: blue; }`
1460    /// - @-rules: `@os linux { font-size: 14px; }`
1461    /// - Nesting: `@os linux { font-size: 14px; :hover { color: red; }}`
1462    /// 
1463    /// Examples:
1464    /// ```ignore
1465    /// // Simple inline styles
1466    /// CssPropertyWithConditionsVec::parse("color: red; font-size: 14px;")
1467    /// 
1468    /// // With hover state
1469    /// CssPropertyWithConditionsVec::parse(":hover { background: blue; }")
1470    /// 
1471    /// // OS-specific with nested hover
1472    /// CssPropertyWithConditionsVec::parse("@os linux { font-size: 14px; :hover { color: red; }}")
1473    /// ```
1474    #[cfg(feature = "parser")]
1475    #[must_use] pub fn parse(style: &str) -> Self {
1476        Self::parse_with_conditions(style, &[])
1477    }
1478    
1479    /// Internal recursive parser with inherited conditions
1480    #[cfg(feature = "parser")]
1481    fn parse_with_conditions(style: &str, inherited_conditions: &[DynamicSelector]) -> Self {
1482        use crate::props::property::{
1483            parse_combined_css_property, parse_css_property, CombinedCssPropertyType, CssKeyMap,
1484            CssPropertyType,
1485        };
1486
1487        let mut props = Vec::new();
1488        let key_map = CssKeyMap::get();
1489        let style = style.trim();
1490        
1491        if style.is_empty() {
1492            return Self::from_vec(props);
1493        }
1494
1495        // Tokenize into segments: properties, pseudo-selectors, and @-rules
1496        let chars = style.chars();
1497        let mut current_segment = String::new();
1498        let mut brace_depth = 0;
1499        
1500        for c in chars {
1501            match c {
1502                '{' => {
1503                    brace_depth += 1;
1504                    current_segment.push(c);
1505                }
1506                '}' => {
1507                    brace_depth -= 1;
1508                    current_segment.push(c);
1509                    
1510                    if brace_depth == 0 {
1511                        // End of a block - process it
1512                        let segment = current_segment.trim().to_string();
1513                        current_segment.clear();
1514                        
1515                        if let Some(parsed) = Self::parse_block_segment(&segment, inherited_conditions, &key_map) {
1516                            props.extend(parsed);
1517                        }
1518                    }
1519                }
1520                ';' if brace_depth == 0 => {
1521                    // End of a simple property
1522                    let segment = current_segment.trim().to_string();
1523                    current_segment.clear();
1524                    
1525                    if !segment.is_empty() {
1526                        if let Some(parsed) = Self::parse_property_segment(&segment, inherited_conditions, &key_map) {
1527                            props.extend(parsed);
1528                        }
1529                    }
1530                }
1531                _ => {
1532                    current_segment.push(c);
1533                }
1534            }
1535        }
1536        
1537        // Handle any remaining segment (property without trailing semicolon)
1538        let remaining = current_segment.trim();
1539        if !remaining.is_empty() && !remaining.contains('{') {
1540            if let Some(parsed) = Self::parse_property_segment(remaining, inherited_conditions, &key_map) {
1541                props.extend(parsed);
1542            }
1543        }
1544
1545        Self::from_vec(props)
1546    }
1547    
1548    /// Parse a block segment like `:hover { ... }` or `@os linux { ... }`
1549    #[cfg(feature = "parser")]
1550    fn parse_block_segment(
1551        segment: &str,
1552        inherited_conditions: &[DynamicSelector],
1553        key_map: &crate::props::property::CssKeyMap,
1554    ) -> Option<Vec<CssPropertyWithConditions>> {
1555        // Find the opening brace
1556        let brace_pos = segment.find('{')?;
1557        let selector = segment[..brace_pos].trim();
1558        
1559        // Extract content between braces (excluding the braces themselves)
1560        let content_start = brace_pos + 1;
1561        let content_end = segment.rfind('}')?;
1562        if content_end <= content_start {
1563            return None;
1564        }
1565        let content = &segment[content_start..content_end];
1566        
1567        // Parse selector to get conditions
1568        let mut conditions = inherited_conditions.to_vec();
1569        
1570        if let Some(new_conditions) = Self::parse_selector_to_conditions(selector) {
1571            conditions.extend(new_conditions);
1572        } else {
1573            // Unknown selector, skip this block
1574            return None;
1575        }
1576        
1577        // Recursively parse the content with the new conditions
1578        let parsed = Self::parse_with_conditions(content, &conditions);
1579        Some(parsed.into_library_owned_vec())
1580    }
1581    
1582    /// Parse a selector string into `DynamicSelector` conditions
1583    #[cfg(feature = "parser")]
1584    fn parse_selector_to_conditions(selector: &str) -> Option<Vec<DynamicSelector>> {
1585        let selector = selector.trim();
1586
1587        // Handle pseudo-selectors
1588        if let Some(pseudo) = selector.strip_prefix(':') {
1589            match pseudo {
1590                "hover" => return Some(vec![DynamicSelector::PseudoState(PseudoStateType::Hover)]),
1591                "active" => return Some(vec![DynamicSelector::PseudoState(PseudoStateType::Active)]),
1592                "focus" => return Some(vec![DynamicSelector::PseudoState(PseudoStateType::Focus)]),
1593                "focus-within" => return Some(vec![DynamicSelector::PseudoState(PseudoStateType::FocusWithin)]),
1594                "disabled" => return Some(vec![DynamicSelector::PseudoState(PseudoStateType::Disabled)]),
1595                "checked" => return Some(vec![DynamicSelector::PseudoState(PseudoStateType::CheckedTrue)]),
1596                "visited" => return Some(vec![DynamicSelector::PseudoState(PseudoStateType::Visited)]),
1597                "backdrop" => return Some(vec![DynamicSelector::PseudoState(PseudoStateType::Backdrop)]),
1598                "dragging" => return Some(vec![DynamicSelector::PseudoState(PseudoStateType::Dragging)]),
1599                "drag-over" => return Some(vec![DynamicSelector::PseudoState(PseudoStateType::DragOver)]),
1600                _ => return None,
1601            }
1602        }
1603
1604        // Handle @-rules
1605        if let Some(rule_content) = selector.strip_prefix('@') {
1606            return Self::parse_at_rule(rule_content);
1607        }
1608
1609        // Handle universal selector * (treat as unconditional)
1610        if selector == "*" {
1611            return Some(vec![]);
1612        }
1613
1614        // Empty selector means unconditional
1615        if selector.is_empty() {
1616            return Some(vec![]);
1617        }
1618        
1619        None
1620    }
1621
1622    /// Parse an @-rule (the content after '@') into `DynamicSelector` conditions.
1623    /// Handles @os, @media, @theme, @lang, @container,
1624    /// @prefers-reduced-motion, and @prefers-high-contrast.
1625    #[cfg(feature = "parser")]
1626    fn parse_at_rule(rule_content: &str) -> Option<Vec<DynamicSelector>> {
1627        // @os linux                    -- bare family
1628        // @os(linux)                   -- family in parens
1629        // @os(linux:gnome)             -- family + desktop env
1630        // @os(windows >= win-11)       -- family + version
1631        // @os(linux:gnome > 40)        -- family + DE + DE version
1632        if let Some(rest) = rule_content
1633            .strip_prefix("os ")
1634            .or_else(|| if rule_content.starts_with("os(") { Some(&rule_content[2..]) } else { None })
1635        {
1636            if let Some(conds) = parse_os_at_rule_content(rest) {
1637                return Some(conds);
1638            }
1639        }
1640
1641        // @media (min-width: 800px), etc.
1642        if let Some(rest) = rule_content.strip_prefix("media ") {
1643            let media_query = rest.trim();
1644            if let Some(media_conds) = Self::parse_media_query(media_query) {
1645                return Some(media_conds);
1646            }
1647        }
1648
1649        // @theme dark, @theme light
1650        if let Some(rest) = rule_content.strip_prefix("theme ") {
1651            let theme = rest.trim();
1652            match theme {
1653                "dark" => return Some(vec![DynamicSelector::Theme(ThemeCondition::Dark)]),
1654                "light" => return Some(vec![DynamicSelector::Theme(ThemeCondition::Light)]),
1655                _ => return None,
1656            }
1657        }
1658
1659        // @lang("de-DE") or @lang de-DE
1660        let lang_body = rule_content
1661            .strip_prefix("lang(")
1662            .map(|r| r.trim_end_matches(')').trim())
1663            .or_else(|| rule_content.strip_prefix("lang ").map(str::trim));
1664        if let Some(lang_str) = lang_body {
1665            let lang_str = lang_str
1666                .strip_prefix('"').and_then(|s| s.strip_suffix('"'))
1667                .or_else(|| lang_str.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
1668                .unwrap_or(lang_str);
1669            if !lang_str.is_empty() {
1670                return Some(vec![DynamicSelector::Language(
1671                    LanguageCondition::Prefix(AzString::from(lang_str.to_string()))
1672                )]);
1673            }
1674        }
1675
1676        // @container (min-width: 400px) or @container sidebar (min-width: 400px)
1677        if rule_content.starts_with("container ") || rule_content.starts_with("container(") {
1678            let container_str = if rule_content.starts_with("container(") {
1679                &rule_content[9..] // keep the '(' for parsing
1680            } else {
1681                rule_content[10..].trim()
1682            };
1683            let mut conds = Vec::new();
1684            // Check for named container: "sidebar (min-width: 400px)"
1685            let (name_part, query_part) = if container_str.starts_with('(') {
1686                (None, container_str)
1687            } else if let Some(paren_idx) = container_str.find('(') {
1688                let name = container_str[..paren_idx].trim();
1689                if name.is_empty() {
1690                    (None, container_str)
1691                } else {
1692                    (Some(name), &container_str[paren_idx..])
1693                }
1694            } else {
1695                if !container_str.is_empty() {
1696                    return Some(vec![DynamicSelector::ContainerName(
1697                        AzString::from(container_str.to_string())
1698                    )]);
1699                }
1700                return None;
1701            };
1702            if let Some(name) = name_part {
1703                conds.push(DynamicSelector::ContainerName(
1704                    AzString::from(name.to_string())
1705                ));
1706            }
1707            // Parse (min-width: 400px) style conditions
1708            if let Some(inner) = query_part.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
1709                if let Some((key, value)) = inner.split_once(':') {
1710                    let key = key.trim();
1711                    let value = value.trim();
1712                    let px_value = value.strip_suffix("px")
1713                        .and_then(|v| v.trim().parse::<f32>().ok());
1714                    match key {
1715                        "min-width" => { if let Some(px) = px_value { conds.push(DynamicSelector::ContainerWidth(MinMaxRange::with_min(px))); } }
1716                        "max-width" => { if let Some(px) = px_value { conds.push(DynamicSelector::ContainerWidth(MinMaxRange::with_max(px))); } }
1717                        "min-height" => { if let Some(px) = px_value { conds.push(DynamicSelector::ContainerHeight(MinMaxRange::with_min(px))); } }
1718                        "max-height" => { if let Some(px) = px_value { conds.push(DynamicSelector::ContainerHeight(MinMaxRange::with_max(px))); } }
1719                        _ => {}
1720                    }
1721                }
1722            }
1723            if !conds.is_empty() {
1724                return Some(conds);
1725            }
1726        }
1727
1728        // @prefers-reduced-motion or @reduced-motion
1729        if rule_content == "prefers-reduced-motion" || rule_content == "reduced-motion" {
1730            return Some(vec![DynamicSelector::PrefersReducedMotion(BoolCondition::True)]);
1731        }
1732
1733        // @prefers-high-contrast or @high-contrast
1734        if rule_content == "prefers-high-contrast" || rule_content == "high-contrast" {
1735            return Some(vec![DynamicSelector::PrefersHighContrast(BoolCondition::True)]);
1736        }
1737
1738        None
1739    }
1740
1741    /// Parse simple media query
1742    #[cfg(feature = "parser")]
1743    fn parse_media_query(query: &str) -> Option<Vec<DynamicSelector>> {
1744        let query = query.trim();
1745        
1746        // Handle (min-width: XXXpx)
1747        if query.starts_with('(') && query.ends_with(')') {
1748            let inner = &query[1..query.len()-1];
1749            if let Some((key, value)) = inner.split_once(':') {
1750                let key = key.trim();
1751                let value = value.trim();
1752                
1753                // Parse pixel value
1754                let px_value = value.strip_suffix("px")
1755                    .and_then(|v| v.trim().parse::<f32>().ok());
1756                
1757                match key {
1758                    "min-width" => {
1759                        if let Some(px) = px_value {
1760                            return Some(vec![DynamicSelector::ViewportWidth(
1761                                MinMaxRange::with_min(px)
1762                            )]);
1763                        }
1764                    }
1765                    "max-width" => {
1766                        if let Some(px) = px_value {
1767                            return Some(vec![DynamicSelector::ViewportWidth(
1768                                MinMaxRange::with_max(px)
1769                            )]);
1770                        }
1771                    }
1772                    "min-height" => {
1773                        if let Some(px) = px_value {
1774                            return Some(vec![DynamicSelector::ViewportHeight(
1775                                MinMaxRange::with_min(px)
1776                            )]);
1777                        }
1778                    }
1779                    "max-height" => {
1780                        if let Some(px) = px_value {
1781                            return Some(vec![DynamicSelector::ViewportHeight(
1782                                MinMaxRange::with_max(px)
1783                            )]);
1784                        }
1785                    }
1786                    other => {
1787                        // Try orientation, prefers-color-scheme, prefers-reduced-motion, etc.
1788                        if let Some(sel) = Self::parse_media_feature_inline(other, value) {
1789                            return Some(vec![sel]);
1790                        }
1791                    }
1792                }
1793            }
1794        }
1795        
1796        // Handle screen, print, all
1797        match query {
1798            "screen" => Some(vec![DynamicSelector::Media(MediaType::Screen)]),
1799            "print" => Some(vec![DynamicSelector::Media(MediaType::Print)]),
1800            "all" => Some(vec![DynamicSelector::Media(MediaType::All)]),
1801            _ => None,
1802        }
1803    }
1804
1805    /// Parse a media query feature value into a `DynamicSelector`
1806    /// Handles features like orientation, prefers-color-scheme, prefers-reduced-motion, etc.
1807    #[cfg(feature = "parser")]
1808    fn parse_media_feature_inline(key: &str, value: &str) -> Option<DynamicSelector> {
1809        match key {
1810            "orientation" => {
1811                if value.eq_ignore_ascii_case("portrait") {
1812                    Some(DynamicSelector::Orientation(OrientationType::Portrait))
1813                } else if value.eq_ignore_ascii_case("landscape") {
1814                    Some(DynamicSelector::Orientation(OrientationType::Landscape))
1815                } else {
1816                    None
1817                }
1818            }
1819            "prefers-color-scheme" => {
1820                if value.eq_ignore_ascii_case("dark") {
1821                    Some(DynamicSelector::Theme(ThemeCondition::Dark))
1822                } else if value.eq_ignore_ascii_case("light") {
1823                    Some(DynamicSelector::Theme(ThemeCondition::Light))
1824                } else {
1825                    None
1826                }
1827            }
1828            "prefers-reduced-motion" => {
1829                if value.eq_ignore_ascii_case("reduce") {
1830                    Some(DynamicSelector::PrefersReducedMotion(BoolCondition::True))
1831                } else if value.eq_ignore_ascii_case("no-preference") {
1832                    Some(DynamicSelector::PrefersReducedMotion(BoolCondition::False))
1833                } else {
1834                    None
1835                }
1836            }
1837            "prefers-contrast" | "prefers-high-contrast" => {
1838                if value.eq_ignore_ascii_case("more") || value.eq_ignore_ascii_case("high") || value.eq_ignore_ascii_case("active") {
1839                    Some(DynamicSelector::PrefersHighContrast(BoolCondition::True))
1840                } else if value.eq_ignore_ascii_case("no-preference") || value.eq_ignore_ascii_case("none") {
1841                    Some(DynamicSelector::PrefersHighContrast(BoolCondition::False))
1842                } else {
1843                    None
1844                }
1845            }
1846            _ => None,
1847        }
1848    }
1849
1850    /// Parse a simple property like "color: red"
1851    #[cfg(feature = "parser")]
1852    fn parse_property_segment(
1853        segment: &str,
1854        inherited_conditions: &[DynamicSelector],
1855        key_map: &crate::props::property::CssKeyMap,
1856    ) -> Option<Vec<CssPropertyWithConditions>> {
1857        use crate::props::property::{
1858            parse_combined_css_property, parse_css_property, CombinedCssPropertyType,
1859            CssPropertyType,
1860        };
1861
1862        let segment = segment.trim();
1863        if segment.is_empty() {
1864            return None;
1865        }
1866        
1867        let (key, value) = segment.split_once(':')?;
1868        let key = key.trim();
1869        let value = value.trim();
1870        
1871        let mut props = Vec::new();
1872        let conditions = if inherited_conditions.is_empty() {
1873            DynamicSelectorVec::from_const_slice(&[])
1874        } else {
1875            DynamicSelectorVec::from_vec(inherited_conditions.to_vec())
1876        };
1877        
1878        // First, try to parse as a regular (non-shorthand) property
1879        if let Some(prop_type) = CssPropertyType::from_str(key, key_map) {
1880            if let Ok(prop) = parse_css_property(prop_type, value) {
1881                props.push(CssPropertyWithConditions {
1882                    property: prop,
1883                    apply_if: conditions,
1884                });
1885                return Some(props);
1886            }
1887        }
1888        
1889        // If not found, try as a shorthand (combined) property
1890        if let Some(combined_type) = CombinedCssPropertyType::from_str(key, key_map) {
1891            if let Ok(expanded_props) = parse_combined_css_property(combined_type, value) {
1892                for prop in expanded_props {
1893                    props.push(CssPropertyWithConditions {
1894                        property: prop,
1895                        apply_if: conditions.clone(),
1896                    });
1897                }
1898                return Some(props);
1899            }
1900        }
1901        
1902        None
1903    }
1904
1905}
1906
1907#[cfg(test)]
1908mod tests {
1909    use super::*;
1910
1911    #[test]
1912    fn test_inline_overflow_parse() {
1913        let style = "overflow: scroll;";
1914        let parsed = CssPropertyWithConditionsVec::parse(style);
1915        let props = parsed.into_library_owned_vec();
1916        assert!(!props.is_empty(), "Expected overflow to parse into at least 1 property");
1917    }
1918
1919    #[test]
1920    fn test_inline_overflow_y_parse() {
1921        let style = "overflow-y: scroll;";
1922        let parsed = CssPropertyWithConditionsVec::parse(style);
1923        let props = parsed.into_library_owned_vec();
1924        assert!(!props.is_empty(), "Expected overflow-y to parse into at least 1 property");
1925    }
1926
1927    #[test]
1928    fn test_inline_combined_style_with_overflow() {
1929        let style = "padding: 20px; background-color: #f0f0f0; font-size: 14px; color: #222;overflow: scroll;";
1930        let parsed = CssPropertyWithConditionsVec::parse(style);
1931        let props = parsed.into_library_owned_vec();
1932        // padding:20px expands to 4, background:1, font-size:1, color:1, overflow:2 = 10
1933        assert!(props.len() >= 9, "Expected at least 9 properties, got {}", props.len());
1934    }
1935
1936    #[test]
1937    fn test_inline_grid_template_columns_parse() {
1938        use crate::props::layout::grid::GridTrackSizing;
1939        let style = "display: grid; grid-template-columns: repeat(4, 160px); gap: 16px; padding: 10px;";
1940        let parsed = CssPropertyWithConditionsVec::parse(style);
1941        let props = parsed.into_library_owned_vec();
1942        // Find grid-template-columns property
1943        let grid_cols = props.iter().find(|p| {
1944            matches!(p.property, CssProperty::GridTemplateColumns(_))
1945        }).expect("Expected GridTemplateColumns property");
1946
1947        if let CssProperty::GridTemplateColumns(ref value) = grid_cols.property {
1948            let template = value.get_property().expect("Expected Exact value");
1949            let tracks = template.tracks.as_ref();
1950            assert_eq!(tracks.len(), 4, "Expected 4 tracks");
1951            for (i, track) in tracks.iter().enumerate() {
1952                assert!(matches!(track, GridTrackSizing::Fixed(_)),
1953                    "Track {i} should be Fixed(160px), got {track:?}");
1954            }
1955        } else {
1956            panic!("Expected CssProperty::GridTemplateColumns");
1957        }
1958    }
1959}
1960
1961#[cfg(test)]
1962#[allow(
1963    clippy::float_cmp,
1964    clippy::too_many_lines,
1965    clippy::cast_precision_loss,
1966    clippy::field_reassign_with_default,
1967    clippy::unreadable_literal
1968)]
1969mod autotest_generated {
1970    use core::cmp::Ordering;
1971    use std::collections::hash_map::DefaultHasher;
1972    use std::hash::{Hash, Hasher};
1973
1974    use super::*;
1975    use crate::props::property::CssPropertyType;
1976
1977    // ---------------------------------------------------------------
1978    // helpers
1979    // ---------------------------------------------------------------
1980
1981    fn hash_of<T: Hash>(t: &T) -> u64 {
1982        let mut h = DefaultHasher::new();
1983        t.hash(&mut h);
1984        h.finish()
1985    }
1986
1987    /// A paint-only property (does not trigger relayout).
1988    fn paint_prop() -> CssProperty {
1989        CssProperty::const_none(CssPropertyType::TextColor)
1990    }
1991
1992    /// A layout-affecting property.
1993    fn layout_prop() -> CssProperty {
1994        CssProperty::const_none(CssPropertyType::Width)
1995    }
1996
1997    /// Every `DynamicSelector` variant, in discriminant order.
1998    fn all_selector_variants() -> Vec<DynamicSelector> {
1999        vec![
2000            DynamicSelector::Os(OsCondition::Linux),
2001            DynamicSelector::OsVersion(OsVersionCondition::Min(OsVersion::WIN_11)),
2002            DynamicSelector::Media(MediaType::Print),
2003            DynamicSelector::ViewportWidth(MinMaxRange::with_min(1.0)),
2004            DynamicSelector::ViewportHeight(MinMaxRange::with_max(2.0)),
2005            DynamicSelector::ContainerWidth(MinMaxRange::new(Some(1.0), Some(2.0))),
2006            DynamicSelector::ContainerHeight(MinMaxRange::new(None, None)),
2007            DynamicSelector::ContainerName(AzString::from_const_str("sidebar")),
2008            DynamicSelector::Theme(ThemeCondition::Dark),
2009            DynamicSelector::AspectRatio(MinMaxRange::with_min(0.5)),
2010            DynamicSelector::Orientation(OrientationType::Portrait),
2011            DynamicSelector::PrefersReducedMotion(BoolCondition::True),
2012            DynamicSelector::PrefersHighContrast(BoolCondition::False),
2013            DynamicSelector::PseudoState(PseudoStateType::Hover),
2014            DynamicSelector::Language(LanguageCondition::Prefix(AzString::from_const_str("de"))),
2015        ]
2016    }
2017
2018    /// Adversarial input corpus reused across every string parser under test.
2019    fn nasty_strings() -> Vec<String> {
2020        vec![
2021            String::new(),
2022            " ".to_string(),
2023            "   \t\n\r  ".to_string(),
2024            "\0".to_string(),
2025            "0".to_string(),
2026            "-0".to_string(),
2027            "-1".to_string(),
2028            "NaN".to_string(),
2029            "nan".to_string(),
2030            "inf".to_string(),
2031            "-inf".to_string(),
2032            "infinity".to_string(),
2033            "1e400".to_string(),
2034            i64::MAX.to_string(),
2035            i64::MIN.to_string(),
2036            u32::MAX.to_string(),
2037            u64::MAX.to_string(),
2038            "9999999999999999999999999999".to_string(),
2039            "1.7976931348623157e308".to_string(),
2040            "\u{1F600}".to_string(),
2041            "e\u{301}\u{301}\u{301}".to_string(),
2042            "日本語".to_string(),
2043            "\u{202e}gnome".to_string(),
2044            "  linux  ".to_string(),
2045            "linux;garbage".to_string(),
2046            "linux)".to_string(),
2047            "((((".to_string(),
2048            "))))".to_string(),
2049            ">=".to_string(),
2050            "<=<=<=".to_string(),
2051            ":::::".to_string(),
2052            "-".to_string(),
2053            "_".to_string(),
2054            ".".to_string(),
2055            "..".to_string(),
2056            "...".to_string(),
2057            "1.2.3.4.5.6".to_string(),
2058            "%s%s%n".to_string(),
2059            "\\x00\\xff".to_string(),
2060            "a".repeat(100_000),
2061            "1".repeat(100_000),
2062            ".".repeat(10_000),
2063            "(".repeat(5_000),
2064            "🦀".repeat(10_000),
2065        ]
2066    }
2067
2068    // ---------------------------------------------------------------
2069    // 1. PseudoStateFlags::has_state  (predicate)
2070    // ---------------------------------------------------------------
2071
2072    #[test]
2073    fn has_state_default_flags_only_normal_and_checked_false() {
2074        let flags = PseudoStateFlags::default();
2075        // Normal is the base state and is always active.
2076        assert!(flags.has_state(PseudoStateType::Normal));
2077        // `checked: false` means :not(:checked) is active.
2078        assert!(flags.has_state(PseudoStateType::CheckedFalse));
2079        for state in [
2080            PseudoStateType::Hover,
2081            PseudoStateType::Active,
2082            PseudoStateType::Focus,
2083            PseudoStateType::Disabled,
2084            PseudoStateType::CheckedTrue,
2085            PseudoStateType::FocusWithin,
2086            PseudoStateType::Visited,
2087            PseudoStateType::Backdrop,
2088            PseudoStateType::Dragging,
2089            PseudoStateType::DragOver,
2090        ] {
2091            assert!(!flags.has_state(state), "{state:?} must be off by default");
2092        }
2093    }
2094
2095    #[test]
2096    fn has_state_all_flags_set_reports_every_state_except_checked_false() {
2097        let flags = PseudoStateFlags {
2098            hover: true,
2099            active: true,
2100            focused: true,
2101            disabled: true,
2102            checked: true,
2103            focus_within: true,
2104            visited: true,
2105            backdrop: true,
2106            dragging: true,
2107            drag_over: true,
2108        };
2109        assert!(flags.has_state(PseudoStateType::Hover));
2110        assert!(flags.has_state(PseudoStateType::CheckedTrue));
2111        // CheckedTrue and CheckedFalse must always be mutually exclusive.
2112        assert!(!flags.has_state(PseudoStateType::CheckedFalse));
2113        assert!(flags.has_state(PseudoStateType::DragOver));
2114        assert!(flags.has_state(PseudoStateType::Normal));
2115    }
2116
2117    #[test]
2118    fn has_state_checked_true_and_false_are_never_both_active() {
2119        for checked in [false, true] {
2120            let flags = PseudoStateFlags {
2121                checked,
2122                ..PseudoStateFlags::default()
2123            };
2124            assert_ne!(
2125                flags.has_state(PseudoStateType::CheckedTrue),
2126                flags.has_state(PseudoStateType::CheckedFalse),
2127                "checked={checked}: CheckedTrue/CheckedFalse must be complementary"
2128            );
2129        }
2130    }
2131
2132    // ---------------------------------------------------------------
2133    // 2. DynamicSelector::variant_tag  (getter)
2134    // ---------------------------------------------------------------
2135
2136    #[test]
2137    fn variant_tag_matches_declared_repr_discriminants() {
2138        for (expected, sel) in all_selector_variants().iter().enumerate() {
2139            let expected = u8::try_from(expected).expect("15 variants fit in u8");
2140            assert_eq!(
2141                sel.variant_tag(),
2142                expected,
2143                "variant_tag drifted from the #[repr(C, u8)] discriminant for {sel:?}"
2144            );
2145        }
2146    }
2147
2148    #[test]
2149    fn variant_tag_is_unique_per_variant() {
2150        let variants = all_selector_variants();
2151        let mut tags: Vec<u8> = variants.iter().map(DynamicSelector::variant_tag).collect();
2152        tags.sort_unstable();
2153        tags.dedup();
2154        assert_eq!(tags.len(), variants.len(), "variant tags must be unique");
2155    }
2156
2157    #[test]
2158    fn ord_is_keyed_on_variant_tag_first() {
2159        let variants = all_selector_variants();
2160        for w in variants.windows(2) {
2161            assert_eq!(
2162                w[0].cmp(&w[1]),
2163                Ordering::Less,
2164                "selectors must sort by variant tag: {:?} < {:?}",
2165                w[0],
2166                w[1]
2167            );
2168        }
2169    }
2170
2171    #[test]
2172    fn hash_distinguishes_variants_carrying_the_same_payload() {
2173        // ViewportWidth / ContainerWidth carry identical payloads but must not collide,
2174        // because `variant_tag` is folded into the hash first.
2175        let range = MinMaxRange::with_min(800.0);
2176        let a = DynamicSelector::ViewportWidth(range);
2177        let b = DynamicSelector::ContainerWidth(range);
2178        assert_ne!(hash_of(&a), hash_of(&b));
2179        assert_ne!(a.cmp(&b), Ordering::Equal);
2180    }
2181
2182    #[test]
2183    fn hash_and_ord_agree_for_nan_carrying_ranges() {
2184        // Both are implemented over the *bit pattern*, so two structurally identical
2185        // NaN-sentinel ranges must compare Equal and hash the same.
2186        let a = DynamicSelector::ViewportWidth(MinMaxRange::with_min(800.0));
2187        let b = DynamicSelector::ViewportWidth(MinMaxRange::with_min(800.0));
2188        assert_eq!(a.cmp(&b), Ordering::Equal);
2189        assert_eq!(hash_of(&a), hash_of(&b));
2190    }
2191
2192    // RED (genuine bug): `MinMaxRange` derives `PartialEq` over raw `f32`s, but the type
2193    // uses NaN as the "no limit" sentinel. NaN != NaN, so a selector built by
2194    // `MinMaxRange::with_min`/`with_max` (i.e. every `@media (min-width: ...)` selector)
2195    // is not even equal to itself. `impl Eq for DynamicSelector` is therefore unsound,
2196    // and `Ord` (which compares bit patterns) reports `Equal` where `PartialEq` reports
2197    // `false` — breaking the Ord/Eq contract for BTreeMap/BTreeSet/dedup.
2198    #[test]
2199    fn nan_sentinel_range_selector_is_reflexive_under_partial_eq() {
2200        let a = DynamicSelector::ViewportWidth(MinMaxRange::with_min(800.0));
2201        let b = DynamicSelector::ViewportWidth(MinMaxRange::with_min(800.0));
2202        assert_eq!(a, b, "Eq requires reflexivity, but the NaN `max` sentinel breaks it");
2203    }
2204
2205    // RED (same root cause, stated as the Ord/Eq contract it violates).
2206    #[test]
2207    fn ord_equal_implies_partial_eq_for_range_selectors() {
2208        let a = DynamicSelector::ViewportHeight(MinMaxRange::with_max(600.0));
2209        let b = a.clone();
2210        assert_eq!(a.cmp(&b), Ordering::Equal);
2211        assert!(
2212            a == b,
2213            "cmp() == Equal must imply == (Ord/Eq contract); NaN sentinel breaks it"
2214        );
2215    }
2216
2217    // ---------------------------------------------------------------
2218    // 3-7. MinMaxRange constructors + getters
2219    // ---------------------------------------------------------------
2220
2221    #[test]
2222    fn min_max_range_new_roundtrips_finite_values() {
2223        let r = MinMaxRange::new(Some(1.5), Some(9.5));
2224        assert_eq!(r.min(), Some(1.5));
2225        assert_eq!(r.max(), Some(9.5));
2226    }
2227
2228    #[test]
2229    fn min_max_range_new_none_encodes_nan_sentinel() {
2230        let r = MinMaxRange::new(None, None);
2231        assert!(r.min.is_nan());
2232        assert!(r.max.is_nan());
2233        assert_eq!(r.min(), None);
2234        assert_eq!(r.max(), None);
2235    }
2236
2237    #[test]
2238    fn min_max_range_new_nan_argument_is_indistinguishable_from_none() {
2239        // Documented sentinel behaviour: NaN *is* "no limit", so a caller passing
2240        // `Some(NAN)` gets `None` back. Assert it rather than letting it surprise.
2241        let r = MinMaxRange::new(Some(f32::NAN), Some(f32::NAN));
2242        assert_eq!(r.min(), None);
2243        assert_eq!(r.max(), None);
2244        assert!(r.matches(0.0));
2245        assert!(r.matches(f32::MAX));
2246    }
2247
2248    #[test]
2249    fn min_max_range_with_min_and_with_max_leave_the_other_side_open() {
2250        let lo = MinMaxRange::with_min(-0.0);
2251        assert_eq!(lo.min(), Some(-0.0));
2252        assert_eq!(lo.max(), None);
2253
2254        let hi = MinMaxRange::with_max(f32::MAX);
2255        assert_eq!(hi.min(), None);
2256        assert_eq!(hi.max(), Some(f32::MAX));
2257    }
2258
2259    #[test]
2260    fn min_max_range_getters_survive_extreme_values() {
2261        for v in [
2262            0.0_f32,
2263            -0.0,
2264            f32::MIN,
2265            f32::MAX,
2266            f32::MIN_POSITIVE,
2267            f32::EPSILON,
2268            f32::INFINITY,
2269            f32::NEG_INFINITY,
2270        ] {
2271            let r = MinMaxRange::new(Some(v), Some(v));
2272            assert_eq!(r.min(), Some(v));
2273            assert_eq!(r.max(), Some(v));
2274        }
2275    }
2276
2277    // ---------------------------------------------------------------
2278    // 8. MinMaxRange::matches  (numeric)
2279    // ---------------------------------------------------------------
2280
2281    #[test]
2282    fn matches_zero_boundary_is_inclusive() {
2283        assert!(MinMaxRange::with_min(0.0).matches(0.0));
2284        assert!(MinMaxRange::with_max(0.0).matches(0.0));
2285        assert!(MinMaxRange::new(Some(0.0), Some(0.0)).matches(0.0));
2286        // IEEE-754: -0.0 == 0.0, so both bounds accept it.
2287        assert!(MinMaxRange::with_min(0.0).matches(-0.0));
2288        assert!(MinMaxRange::with_max(0.0).matches(-0.0));
2289    }
2290
2291    #[test]
2292    fn matches_negative_values_are_ordered_correctly() {
2293        let r = MinMaxRange::new(Some(-10.0), Some(-1.0));
2294        assert!(r.matches(-10.0));
2295        assert!(r.matches(-5.0));
2296        assert!(r.matches(-1.0));
2297        assert!(!r.matches(-10.001));
2298        assert!(!r.matches(0.0));
2299    }
2300
2301    #[test]
2302    fn matches_at_float_extremes_does_not_panic() {
2303        let open = MinMaxRange::new(None, None);
2304        let bounded = MinMaxRange::new(Some(f32::MIN), Some(f32::MAX));
2305        for v in [
2306            f32::MIN,
2307            f32::MAX,
2308            f32::INFINITY,
2309            f32::NEG_INFINITY,
2310            f32::MIN_POSITIVE,
2311            -f32::MIN_POSITIVE,
2312        ] {
2313            // Open range accepts everything (both sentinels are NaN).
2314            assert!(open.matches(v), "open range must accept {v}");
2315        }
2316        assert!(bounded.matches(0.0));
2317        assert!(bounded.matches(f32::MIN));
2318        assert!(bounded.matches(f32::MAX));
2319        // Infinities fall outside a MIN..=MAX range.
2320        assert!(!bounded.matches(f32::INFINITY));
2321        assert!(!bounded.matches(f32::NEG_INFINITY));
2322    }
2323
2324    #[test]
2325    fn matches_nan_value_is_rejected_by_any_real_bound() {
2326        // NaN compares false against everything, so any *actual* bound rejects it.
2327        assert!(!MinMaxRange::with_min(0.0).matches(f32::NAN));
2328        assert!(!MinMaxRange::with_max(0.0).matches(f32::NAN));
2329        assert!(!MinMaxRange::new(Some(1.0), Some(2.0)).matches(f32::NAN));
2330        // ...but a fully-open range has no bound to reject it.
2331        assert!(MinMaxRange::new(None, None).matches(f32::NAN));
2332    }
2333
2334    #[test]
2335    fn matches_infinite_bounds_are_deterministic() {
2336        let min_inf = MinMaxRange::with_min(f32::INFINITY);
2337        assert!(!min_inf.matches(f32::MAX));
2338        assert!(min_inf.matches(f32::INFINITY));
2339
2340        let max_neg_inf = MinMaxRange::with_max(f32::NEG_INFINITY);
2341        assert!(!max_neg_inf.matches(f32::MIN));
2342        assert!(max_neg_inf.matches(f32::NEG_INFINITY));
2343    }
2344
2345    #[test]
2346    fn matches_inverted_range_matches_nothing() {
2347        let inverted = MinMaxRange::new(Some(10.0), Some(5.0));
2348        for v in [-1.0_f32, 0.0, 5.0, 7.5, 10.0, 1e30] {
2349            assert!(!inverted.matches(v), "inverted range must reject {v}");
2350        }
2351    }
2352
2353    // ---------------------------------------------------------------
2354    // 9. OsCondition::from_system_platform  (constructor)
2355    // ---------------------------------------------------------------
2356
2357    #[test]
2358    fn os_condition_from_system_platform_covers_every_platform() {
2359        use crate::system::{DesktopEnvironment, Platform};
2360        assert_eq!(
2361            OsCondition::from_system_platform(&Platform::Windows),
2362            OsCondition::Windows
2363        );
2364        assert_eq!(
2365            OsCondition::from_system_platform(&Platform::MacOs),
2366            OsCondition::MacOS
2367        );
2368        assert_eq!(
2369            OsCondition::from_system_platform(&Platform::Ios),
2370            OsCondition::IOS
2371        );
2372        assert_eq!(
2373            OsCondition::from_system_platform(&Platform::Android),
2374            OsCondition::Android
2375        );
2376        assert_eq!(
2377            OsCondition::from_system_platform(&Platform::Linux(DesktopEnvironment::Gnome)),
2378            OsCondition::Linux
2379        );
2380        assert_eq!(
2381            OsCondition::from_system_platform(&Platform::Linux(DesktopEnvironment::Other(
2382                AzString::from_const_str(""),
2383            ))),
2384            OsCondition::Linux
2385        );
2386        // Unknown degrades to `Any`, which `match_os` treats as always-true.
2387        assert_eq!(
2388            OsCondition::from_system_platform(&Platform::Unknown),
2389            OsCondition::Any
2390        );
2391    }
2392
2393    // ---------------------------------------------------------------
2394    // 10-15. OsVersion constructor / compare / predicates / unknown
2395    // ---------------------------------------------------------------
2396
2397    #[test]
2398    fn os_version_new_stores_fields_verbatim_at_boundaries() {
2399        for id in [0, 1, u32::MAX - 1, u32::MAX] {
2400            let v = OsVersion::new(OsFamily::Linux, id);
2401            assert_eq!(v.os, OsFamily::Linux);
2402            assert_eq!(v.version_id, id);
2403        }
2404    }
2405
2406    #[test]
2407    fn os_version_unknown_is_the_default_and_has_id_zero() {
2408        let u = OsVersion::unknown();
2409        assert_eq!(u.version_id, 0);
2410        assert_eq!(u, OsVersion::default());
2411    }
2412
2413    #[test]
2414    fn os_version_compare_is_none_across_families() {
2415        let win = OsVersion::WIN_11;
2416        let mac = OsVersion::MACOS_SONOMA;
2417        assert_eq!(win.compare(&mac), None);
2418        assert_eq!(mac.compare(&win), None);
2419        // A None comparison must make *all three* predicates false — a cross-OS
2420        // condition can never accidentally match.
2421        assert!(!win.is_at_least(&mac));
2422        assert!(!win.is_at_most(&mac));
2423        assert!(!win.is_exactly(&mac));
2424    }
2425
2426    #[test]
2427    fn os_version_compare_within_family_orders_by_id() {
2428        assert_eq!(
2429            OsVersion::WIN_10.compare(&OsVersion::WIN_11),
2430            Some(Ordering::Less)
2431        );
2432        assert_eq!(
2433            OsVersion::WIN_11.compare(&OsVersion::WIN_10),
2434            Some(Ordering::Greater)
2435        );
2436        assert_eq!(
2437            OsVersion::WIN_11.compare(&OsVersion::WIN_11_21H2),
2438            Some(Ordering::Equal)
2439        );
2440    }
2441
2442    #[test]
2443    fn os_version_compare_at_id_extremes() {
2444        let lo = OsVersion::new(OsFamily::Android, 0);
2445        let hi = OsVersion::new(OsFamily::Android, u32::MAX);
2446        assert_eq!(lo.compare(&hi), Some(Ordering::Less));
2447        assert_eq!(hi.compare(&lo), Some(Ordering::Greater));
2448        assert!(hi.is_at_least(&lo));
2449        assert!(!hi.is_at_most(&lo));
2450        assert!(lo.is_at_most(&hi));
2451    }
2452
2453    #[test]
2454    fn os_version_predicates_are_reflexive_and_consistent() {
2455        for v in [
2456            OsVersion::unknown(),
2457            OsVersion::WIN_XP,
2458            OsVersion::MACOS_TAHOE,
2459            OsVersion::IOS_18,
2460            OsVersion::ANDROID_15,
2461            OsVersion::new(OsFamily::Linux, u32::MAX),
2462        ] {
2463            assert!(v.is_at_least(&v), "{v:?} >= itself");
2464            assert!(v.is_at_most(&v), "{v:?} <= itself");
2465            assert!(v.is_exactly(&v), "{v:?} == itself");
2466        }
2467    }
2468
2469    #[test]
2470    fn os_version_at_least_is_the_strict_complement_of_less_than() {
2471        let a = OsVersion::WIN_10;
2472        let b = OsVersion::WIN_11;
2473        assert!(!a.is_at_least(&b));
2474        assert!(a.is_at_most(&b));
2475        assert!(!a.is_exactly(&b));
2476        assert!(b.is_at_least(&a));
2477        assert!(!b.is_at_most(&a));
2478    }
2479
2480    #[test]
2481    fn os_version_unknown_never_satisfies_a_min_constraint_on_a_real_version() {
2482        // `unknown()` reports OsFamily::Linux/0 — it must not silently satisfy
2483        // "at least Windows 11" (different family) nor "at least Linux 6.0".
2484        assert!(!OsVersion::unknown().is_at_least(&OsVersion::WIN_11));
2485        assert!(!OsVersion::unknown().is_at_least(&OsVersion::LINUX_6_0));
2486    }
2487
2488    // ---------------------------------------------------------------
2489    // 16-22. OS version parsers
2490    // ---------------------------------------------------------------
2491
2492    #[test]
2493    fn parse_os_version_valid_minimal_positive_controls() {
2494        assert_eq!(
2495            parse_os_version(OsFamily::Windows, "11"),
2496            Some(OsVersion::WIN_11)
2497        );
2498        assert_eq!(
2499            parse_os_version(OsFamily::MacOS, "sonoma"),
2500            Some(OsVersion::MACOS_SONOMA)
2501        );
2502        assert_eq!(
2503            parse_os_version(OsFamily::IOS, "17.0"),
2504            Some(OsVersion::IOS_17)
2505        );
2506        assert_eq!(
2507            parse_os_version(OsFamily::Android, "tiramisu"),
2508            Some(OsVersion::ANDROID_13)
2509        );
2510        assert_eq!(
2511            parse_os_version(OsFamily::Linux, "6.0"),
2512            Some(OsVersion::LINUX_6_0)
2513        );
2514    }
2515
2516    #[test]
2517    fn parse_os_version_trims_and_lowercases() {
2518        assert_eq!(
2519            parse_os_version(OsFamily::Windows, "  WIN-11  "),
2520            Some(OsVersion::WIN_11)
2521        );
2522        assert_eq!(
2523            parse_os_version(OsFamily::MacOS, "\tBIG-SUR\n"),
2524            Some(OsVersion::MACOS_BIG_SUR)
2525        );
2526        assert_eq!(
2527            parse_os_version(OsFamily::Android, " KitKat "),
2528            Some(OsVersion::ANDROID_KITKAT)
2529        );
2530    }
2531
2532    #[test]
2533    fn parse_os_version_empty_and_whitespace_is_none_for_every_family() {
2534        for os in [
2535            OsFamily::Windows,
2536            OsFamily::MacOS,
2537            OsFamily::IOS,
2538            OsFamily::Android,
2539            OsFamily::Linux,
2540        ] {
2541            assert_eq!(parse_os_version(os, ""), None, "{os:?} empty");
2542            assert_eq!(parse_os_version(os, "   "), None, "{os:?} spaces");
2543            assert_eq!(parse_os_version(os, "\t\n\r"), None, "{os:?} ws");
2544        }
2545    }
2546
2547    #[test]
2548    fn parse_os_version_garbage_and_unicode_never_panics() {
2549        for os in [
2550            OsFamily::Windows,
2551            OsFamily::MacOS,
2552            OsFamily::IOS,
2553            OsFamily::Android,
2554            OsFamily::Linux,
2555        ] {
2556            for s in nasty_strings() {
2557                // Only requirement: terminate, do not panic, be deterministic.
2558                let a = parse_os_version(os, &s);
2559                let b = parse_os_version(os, &s);
2560                assert_eq!(a, b, "{os:?} not deterministic for {s:?}");
2561            }
2562        }
2563    }
2564
2565    #[test]
2566    fn parse_os_version_leading_trailing_junk_is_rejected() {
2567        assert_eq!(parse_os_version(OsFamily::Windows, "win-11;drop"), None);
2568        assert_eq!(parse_os_version(OsFamily::MacOS, "sonoma!"), None);
2569        assert_eq!(parse_os_version(OsFamily::IOS, "17.0.0.0"), None);
2570        assert_eq!(parse_os_version(OsFamily::Android, "api"), None);
2571        assert_eq!(parse_os_version(OsFamily::Android, "api abc"), None);
2572    }
2573
2574    #[test]
2575    fn parse_windows_version_prefix_forms_all_collapse_to_the_same_version() {
2576        for s in [
2577            "11",
2578            "win11",
2579            "win-11",
2580            "win_11",
2581            "windows11",
2582            "windows-11",
2583            "windows_11",
2584        ] {
2585            assert_eq!(
2586                parse_windows_version(s),
2587                Some(OsVersion::WIN_11),
2588                "{s} should parse to WIN_11"
2589            );
2590        }
2591    }
2592
2593    #[test]
2594    fn parse_windows_version_bare_prefix_and_separator_only_are_none() {
2595        assert_eq!(parse_windows_version("win"), None);
2596        assert_eq!(parse_windows_version("windows"), None);
2597        assert_eq!(parse_windows_version("win-"), None);
2598        assert_eq!(parse_windows_version("windows_"), None);
2599        assert_eq!(parse_windows_version(""), None);
2600    }
2601
2602    #[test]
2603    fn parse_windows_version_nt_aliases_agree_with_names() {
2604        assert_eq!(parse_windows_version("xp"), parse_windows_version("nt5.1"));
2605        assert_eq!(parse_windows_version("vista"), parse_windows_version("6.0"));
2606        assert_eq!(parse_windows_version("8.1"), parse_windows_version("8-1"));
2607        assert_eq!(parse_windows_version("10"), Some(OsVersion::WIN_10));
2608    }
2609
2610    #[test]
2611    fn parse_windows_version_boundary_numbers_are_none() {
2612        for s in ["0", "-0", "-1", "NaN", "inf", "99999999999999999999"] {
2613            assert_eq!(parse_windows_version(s), None, "{s} must not parse");
2614        }
2615    }
2616
2617    #[test]
2618    fn parse_windows_version_huge_and_unicode_input_terminates() {
2619        assert_eq!(parse_windows_version(&"win".repeat(200_000)), None);
2620        assert_eq!(parse_windows_version(&"1".repeat(1_000_000)), None);
2621        assert_eq!(parse_windows_version("\u{1F600}"), None);
2622        assert_eq!(parse_windows_version("win-\u{1F600}"), None);
2623    }
2624
2625    #[test]
2626    fn strip_os_prefix_strips_prefix_and_optional_separator() {
2627        assert_eq!(strip_os_prefix("win-11", &["win"]), "11");
2628        assert_eq!(strip_os_prefix("win_11", &["win"]), "11");
2629        assert_eq!(strip_os_prefix("win11", &["win"]), "11");
2630        // Longest prefix must be listed first; that ordering is the caller's job.
2631        assert_eq!(strip_os_prefix("windows-11", &["windows", "win"]), "11");
2632        assert_eq!(strip_os_prefix("windows-11", &["win", "windows"]), "dows-11");
2633    }
2634
2635    #[test]
2636    fn strip_os_prefix_leaves_non_matching_input_untouched() {
2637        assert_eq!(strip_os_prefix("11", &["win"]), "11");
2638        assert_eq!(strip_os_prefix("", &["win"]), "");
2639        assert_eq!(strip_os_prefix("anything", &[]), "anything");
2640        assert_eq!(strip_os_prefix("\u{1F600}win", &["win"]), "\u{1F600}win");
2641    }
2642
2643    #[test]
2644    fn strip_os_prefix_only_strips_one_separator() {
2645        assert_eq!(strip_os_prefix("win--11", &["win"]), "-11");
2646        assert_eq!(strip_os_prefix("win-", &["win"]), "");
2647        assert_eq!(strip_os_prefix("win", &["win"]), "");
2648    }
2649
2650    #[test]
2651    fn strip_os_prefix_with_empty_prefix_is_identity() {
2652        // An empty prefix matches everything; it must still not eat a leading char
2653        // other than a separator, and must not panic on multibyte input.
2654        assert_eq!(strip_os_prefix("日本語", &[""]), "日本語");
2655        assert_eq!(strip_os_prefix("-日本語", &[""]), "日本語");
2656    }
2657
2658    #[test]
2659    fn parse_macos_version_names_and_numbers_agree() {
2660        assert_eq!(parse_macos_version("cheetah"), parse_macos_version("10.0"));
2661        assert_eq!(parse_macos_version("big-sur"), parse_macos_version("bigsur"));
2662        assert_eq!(parse_macos_version("bigsur"), parse_macos_version("11.0"));
2663        assert_eq!(parse_macos_version("tahoe"), Some(OsVersion::MACOS_TAHOE));
2664        assert_eq!(
2665            parse_macos_version("snow-leopard"),
2666            parse_macos_version("snowleopard")
2667        );
2668    }
2669
2670    #[test]
2671    fn parse_macos_version_rejects_junk_and_terminates_on_huge_input() {
2672        for s in ["", " ", "sonoma ", "SONOMA", "10.16", "27", "🍎"] {
2673            assert_eq!(parse_macos_version(s), None, "{s:?} must not parse");
2674        }
2675        assert_eq!(parse_macos_version(&"10.".repeat(100_000)), None);
2676    }
2677
2678    #[test]
2679    fn parse_macos_version_is_ordered_monotonically() {
2680        let names = [
2681            "cheetah", "puma", "jaguar", "panther", "tiger", "leopard", "lion", "mojave",
2682            "catalina", "bigsur", "monterey", "ventura", "sonoma", "sequoia", "tahoe",
2683        ];
2684        let ids: Vec<u32> = names
2685            .iter()
2686            .map(|n| {
2687                parse_macos_version(n)
2688                    .unwrap_or_else(|| panic!("{n} must parse"))
2689                    .version_id
2690            })
2691            .collect();
2692        for w in ids.windows(2) {
2693            assert!(w[0] < w[1], "macOS version ids must increase: {w:?}");
2694        }
2695    }
2696
2697    #[test]
2698    fn parse_ios_version_boundaries() {
2699        assert_eq!(parse_ios_version("1"), Some(OsVersion::IOS_1));
2700        assert_eq!(parse_ios_version("18.0"), Some(OsVersion::IOS_18));
2701        assert_eq!(parse_ios_version("0"), None);
2702        assert_eq!(parse_ios_version("19"), None);
2703        assert_eq!(parse_ios_version(""), None);
2704        assert_eq!(parse_ios_version("-1"), None);
2705        assert_eq!(parse_ios_version("NaN"), None);
2706        assert_eq!(parse_ios_version(&"9".repeat(500_000)), None);
2707    }
2708
2709    #[test]
2710    fn parse_android_version_api_level_escape_hatch() {
2711        assert_eq!(
2712            parse_android_version("api34"),
2713            Some(OsVersion::new(OsFamily::Android, 34))
2714        );
2715        assert_eq!(
2716            parse_android_version("api 34"),
2717            Some(OsVersion::new(OsFamily::Android, 34))
2718        );
2719        assert_eq!(
2720            parse_android_version("api0"),
2721            Some(OsVersion::new(OsFamily::Android, 0))
2722        );
2723        assert_eq!(
2724            parse_android_version(&format!("api{}", u32::MAX)),
2725            Some(OsVersion::new(OsFamily::Android, u32::MAX))
2726        );
2727    }
2728
2729    #[test]
2730    fn parse_android_version_api_level_out_of_range_is_none_not_a_panic() {
2731        // u32::MAX + 1 and beyond must be rejected by `parse::<u32>()`, not wrap.
2732        assert_eq!(parse_android_version("api4294967296"), None);
2733        assert_eq!(parse_android_version("api-1"), None);
2734        assert_eq!(parse_android_version("api+1"), Some(OsVersion::new(OsFamily::Android, 1)));
2735        assert_eq!(parse_android_version("apiNaN"), None);
2736        assert_eq!(parse_android_version(&format!("api{}", "9".repeat(100_000))), None);
2737    }
2738
2739    #[test]
2740    fn parse_android_version_named_releases() {
2741        assert_eq!(parse_android_version("q"), Some(OsVersion::ANDROID_10));
2742        assert_eq!(parse_android_version("13"), parse_android_version("t"));
2743        assert_eq!(
2744            parse_android_version("13"),
2745            parse_android_version("tiramisu")
2746        );
2747        assert_eq!(parse_android_version("15"), Some(OsVersion::ANDROID_15));
2748        assert_eq!(parse_android_version(""), None);
2749        assert_eq!(parse_android_version("🤖"), None);
2750    }
2751
2752    #[test]
2753    fn parse_linux_version_accepts_bare_major_and_prefixes() {
2754        assert_eq!(
2755            parse_linux_version("5"),
2756            Some(OsVersion::new(OsFamily::Linux, 5000))
2757        );
2758        assert_eq!(parse_linux_version("6.0"), Some(OsVersion::LINUX_6_0));
2759        assert_eq!(parse_linux_version("linux6.0"), Some(OsVersion::LINUX_6_0));
2760        assert_eq!(parse_linux_version("linux-6.0"), Some(OsVersion::LINUX_6_0));
2761        assert_eq!(parse_linux_version("linux_6.0"), Some(OsVersion::LINUX_6_0));
2762        assert_eq!(
2763            parse_linux_version("6.17.0"),
2764            Some(OsVersion::new(OsFamily::Linux, 6170))
2765        );
2766    }
2767
2768    #[test]
2769    fn parse_linux_version_rejects_malformed_and_unicode() {
2770        for s in [
2771            "", " ", ".", "..", "-1", "6.-1", "6.x", "x.6", "NaN", "inf", "🐧", "linux", "linux-",
2772        ] {
2773            assert_eq!(parse_linux_version(s), None, "{s:?} must not parse");
2774        }
2775    }
2776
2777    #[test]
2778    fn parse_linux_version_ignores_everything_past_the_patch_component() {
2779        // Only major/minor/patch are consumed by `split('.')`; anything after the third
2780        // component is silently dropped — including outright garbage. Pinning the
2781        // behaviour so a future tightening is a deliberate change, not a surprise.
2782        assert_eq!(
2783            parse_linux_version("6.1.2"),
2784            Some(OsVersion::new(OsFamily::Linux, 6012))
2785        );
2786        assert_eq!(
2787            parse_linux_version("6.1.2.3"),
2788            Some(OsVersion::new(OsFamily::Linux, 6012))
2789        );
2790        assert_eq!(
2791            parse_linux_version("6.0.0.0extra"),
2792            Some(OsVersion::LINUX_6_0),
2793            "a 4th component is never parsed, so trailing junk is accepted"
2794        );
2795    }
2796
2797    // RED (genuine bug): `major * 1000 + minor * 10 + patch` is unchecked u32 arithmetic.
2798    // A major >= 4_294_968 overflows and panics in any debug/overflow-checks build, and
2799    // wraps silently in release. The input is attacker-reachable from CSS via
2800    // `@os(linux >= 5000000)`, so a stylesheet can crash the app.
2801    #[test]
2802    fn parse_linux_version_huge_major_does_not_overflow() {
2803        assert_eq!(
2804            parse_linux_version("5000000"),
2805            None,
2806            "out-of-range kernel major must be rejected, not overflow u32"
2807        );
2808    }
2809
2810    // RED (same root cause, via the minor component: `minor * 10` overflows).
2811    #[test]
2812    fn parse_linux_version_huge_minor_does_not_overflow() {
2813        assert_eq!(
2814            parse_linux_version("1.999999999"),
2815            None,
2816            "out-of-range kernel minor must be rejected, not overflow u32"
2817        );
2818    }
2819
2820    #[test]
2821    fn parse_linux_version_max_u32_component_is_rejected_by_parse_not_by_math() {
2822        // `u32::MAX + 1` fails `parse::<u32>()` before any multiplication happens.
2823        assert_eq!(parse_linux_version("4294967296"), None);
2824    }
2825
2826    // ---------------------------------------------------------------
2827    // 23-24. LinuxDesktopEnv / ThemeCondition converters
2828    // ---------------------------------------------------------------
2829
2830    #[test]
2831    fn linux_desktop_env_from_system_maps_unknown_des_to_other() {
2832        use crate::system::DesktopEnvironment;
2833        assert_eq!(
2834            LinuxDesktopEnv::from_system_desktop_env(&DesktopEnvironment::Gnome),
2835            LinuxDesktopEnv::Gnome
2836        );
2837        assert_eq!(
2838            LinuxDesktopEnv::from_system_desktop_env(&DesktopEnvironment::Kde),
2839            LinuxDesktopEnv::KDE
2840        );
2841        // XFCE/Unity/Cinnamon/MATE are parse-only: they collapse to `Other` at runtime.
2842        for name in ["xfce", "", "🖥", &"x".repeat(10_000)] {
2843            assert_eq!(
2844                LinuxDesktopEnv::from_system_desktop_env(&DesktopEnvironment::Other(
2845                    AzString::from(name.to_string())
2846                )),
2847                LinuxDesktopEnv::Other
2848            );
2849        }
2850    }
2851
2852    #[test]
2853    fn theme_condition_from_system_theme_is_total() {
2854        use crate::system::Theme;
2855        assert_eq!(
2856            ThemeCondition::from_system_theme(Theme::Light),
2857            ThemeCondition::Light
2858        );
2859        assert_eq!(
2860            ThemeCondition::from_system_theme(Theme::Dark),
2861            ThemeCondition::Dark
2862        );
2863    }
2864
2865    // ---------------------------------------------------------------
2866    // 25. LanguageCondition::matches
2867    // ---------------------------------------------------------------
2868
2869    #[test]
2870    fn language_exact_is_case_insensitive_and_strict() {
2871        let cond = LanguageCondition::Exact(AzString::from_const_str("de-DE"));
2872        assert!(cond.matches("de-DE"));
2873        assert!(cond.matches("DE-de"));
2874        assert!(!cond.matches("de"));
2875        assert!(!cond.matches("de-AT"));
2876        assert!(!cond.matches("de-DE-x"));
2877        assert!(!cond.matches(""));
2878    }
2879
2880    #[test]
2881    fn language_prefix_matches_subtags_only_at_a_dash_boundary() {
2882        let cond = LanguageCondition::Prefix(AzString::from_const_str("de"));
2883        assert!(cond.matches("de"));
2884        assert!(cond.matches("de-DE"));
2885        assert!(cond.matches("DE-at"));
2886        // "den" must NOT match prefix "de" — the boundary has to be '-'.
2887        assert!(!cond.matches("den"));
2888        assert!(!cond.matches("deu"));
2889        assert!(!cond.matches("d"));
2890        assert!(!cond.matches(""));
2891    }
2892
2893    #[test]
2894    fn language_empty_prefix_matches_everything_ascii() {
2895        let cond = LanguageCondition::Prefix(AzString::from_const_str(""));
2896        assert!(cond.matches(""));
2897        assert!(cond.matches("en-US"));
2898    }
2899
2900    #[test]
2901    fn language_prefix_longer_than_input_is_false() {
2902        let cond = LanguageCondition::Prefix(AzString::from_const_str("de-DE-1996"));
2903        assert!(!cond.matches("de"));
2904        assert!(!cond.matches(""));
2905    }
2906
2907    #[test]
2908    fn language_matches_huge_input_terminates() {
2909        let cond = LanguageCondition::Prefix(AzString::from_const_str("en"));
2910        let huge = format!("en-{}", "a".repeat(1_000_000));
2911        assert!(cond.matches(&huge));
2912        let cond_exact = LanguageCondition::Exact(AzString::from_const_str("en"));
2913        assert!(!cond_exact.matches(&huge));
2914    }
2915
2916    // RED (genuine bug, PANIC): `LanguageCondition::Prefix` slices the language tag with
2917    // `&language[..prefix_str.len()]` — a *byte* index. When the runtime language tag is
2918    // non-ASCII (or merely multibyte), that index can land inside a UTF-8 code point and
2919    // `str` indexing panics. `@lang("de")` + a system locale reported as e.g. "日本語"
2920    // aborts style resolution.
2921    #[test]
2922    fn language_prefix_does_not_panic_on_multibyte_language_tag() {
2923        let cond = LanguageCondition::Prefix(AzString::from_const_str("de"));
2924        // 2-byte prefix index falls inside the 3-byte '日'.
2925        assert!(!cond.matches("日本語"));
2926    }
2927
2928    // RED (same root cause, 1-byte prefix into a 2-byte char).
2929    #[test]
2930    fn language_prefix_does_not_panic_on_two_byte_language_tag() {
2931        let cond = LanguageCondition::Prefix(AzString::from_const_str("d"));
2932        assert!(!cond.matches("é"));
2933    }
2934
2935    // ---------------------------------------------------------------
2936    // 26-30. DynamicSelectorContext
2937    // ---------------------------------------------------------------
2938
2939    #[test]
2940    fn context_from_system_style_default_is_coherent() {
2941        let style = crate::system::SystemStyle::default();
2942        let ctx = DynamicSelectorContext::from_system_style(&style);
2943        // Platform::Unknown -> OsCondition::Any, and no desktop env.
2944        assert_eq!(ctx.os, OsCondition::Any);
2945        assert_eq!(ctx.desktop_env, OptionLinuxDesktopEnv::None);
2946        assert_eq!(ctx.de_version, 0);
2947        assert_eq!(ctx.theme, ThemeCondition::Light);
2948        assert_eq!(ctx.media_type, MediaType::Screen);
2949        assert_eq!(ctx.viewport_width, DEFAULT_VIEWPORT_WIDTH);
2950        assert_eq!(ctx.viewport_height, DEFAULT_VIEWPORT_HEIGHT);
2951        // "no container" is encoded as NaN, and must therefore never match a
2952        // @container query.
2953        assert!(ctx.container_width.is_nan());
2954        assert!(ctx.container_height.is_nan());
2955        assert!(ctx.window_focused);
2956    }
2957
2958    #[test]
2959    fn context_from_system_style_linux_carries_the_desktop_env() {
2960        use crate::system::{DesktopEnvironment, Platform};
2961        let mut style = crate::system::SystemStyle::default();
2962        style.platform = Platform::Linux(DesktopEnvironment::Kde);
2963        let ctx = DynamicSelectorContext::from_system_style(&style);
2964        assert_eq!(ctx.os, OsCondition::Linux);
2965        assert_eq!(
2966            ctx.desktop_env,
2967            OptionLinuxDesktopEnv::Some(LinuxDesktopEnv::KDE)
2968        );
2969    }
2970
2971    #[test]
2972    fn with_viewport_updates_orientation_and_is_deterministic_at_extremes() {
2973        let base = DynamicSelectorContext::default();
2974        assert_eq!(
2975            base.with_viewport(1920.0, 1080.0).orientation,
2976            OrientationType::Landscape
2977        );
2978        assert_eq!(
2979            base.with_viewport(1080.0, 1920.0).orientation,
2980            OrientationType::Portrait
2981        );
2982        // Square is *not* landscape (strict `>`), by construction.
2983        assert_eq!(
2984            base.with_viewport(500.0, 500.0).orientation,
2985            OrientationType::Portrait
2986        );
2987        // NaN comparisons are all false -> Portrait. Deterministic, no panic.
2988        assert_eq!(
2989            base.with_viewport(f32::NAN, f32::NAN).orientation,
2990            OrientationType::Portrait
2991        );
2992        assert_eq!(
2993            base.with_viewport(f32::INFINITY, f32::NEG_INFINITY).orientation,
2994            OrientationType::Landscape
2995        );
2996        // Zero / negative sizes must not panic.
2997        let z = base.with_viewport(0.0, 0.0);
2998        assert_eq!(z.viewport_width, 0.0);
2999        assert_eq!(z.orientation, OrientationType::Portrait);
3000        let neg = base.with_viewport(-100.0, -200.0);
3001        assert_eq!(neg.orientation, OrientationType::Landscape);
3002    }
3003
3004    #[test]
3005    fn with_viewport_does_not_disturb_unrelated_fields() {
3006        let base = DynamicSelectorContext::default();
3007        let updated = base.with_viewport(1.0, 2.0);
3008        assert_eq!(updated.os, base.os);
3009        assert_eq!(updated.theme, base.theme);
3010        assert_eq!(updated.language, base.language);
3011        assert_eq!(updated.pseudo_state, base.pseudo_state);
3012    }
3013
3014    #[test]
3015    fn with_container_stores_name_and_extreme_dimensions() {
3016        let base = DynamicSelectorContext::default();
3017        let named = base.with_container(
3018            f32::MAX,
3019            f32::NEG_INFINITY,
3020            Some(AzString::from_const_str("sidebar")),
3021        );
3022        assert_eq!(named.container_width, f32::MAX);
3023        assert_eq!(named.container_height, f32::NEG_INFINITY);
3024        assert_eq!(
3025            named.container_name.as_ref(),
3026            Some(&AzString::from_const_str("sidebar"))
3027        );
3028
3029        let unnamed = base.with_container(0.0, 0.0, None);
3030        assert_eq!(unnamed.container_name.as_ref(), None);
3031    }
3032
3033    #[test]
3034    fn with_container_nan_dimensions_stay_unmatched() {
3035        let ctx = DynamicSelectorContext::default().with_container(f32::NAN, f32::NAN, None);
3036        // NaN container size means "no container": the guard in `matches` must reject
3037        // even a fully-open range.
3038        let open = DynamicSelector::ContainerWidth(MinMaxRange::new(None, None));
3039        assert!(!open.matches(&ctx));
3040        let open_h = DynamicSelector::ContainerHeight(MinMaxRange::new(None, None));
3041        assert!(!open_h.matches(&ctx));
3042    }
3043
3044    #[test]
3045    fn with_pseudo_state_replaces_the_whole_flag_set() {
3046        let base = DynamicSelectorContext::default();
3047        let hovered = base.with_pseudo_state(PseudoStateFlags {
3048            hover: true,
3049            ..PseudoStateFlags::default()
3050        });
3051        assert!(hovered.pseudo_state.hover);
3052        assert!(!hovered.pseudo_state.active);
3053        // Replacing again must not OR the previous state in.
3054        let active = hovered.with_pseudo_state(PseudoStateFlags {
3055            active: true,
3056            ..PseudoStateFlags::default()
3057        });
3058        assert!(!active.pseudo_state.hover);
3059        assert!(active.pseudo_state.active);
3060    }
3061
3062    #[test]
3063    fn viewport_breakpoint_changed_detects_crossings_only() {
3064        let bps = [480.0_f32, 768.0, 1024.0];
3065        let base = DynamicSelectorContext::default();
3066        let small = base.with_viewport(320.0, 480.0);
3067        let medium = base.with_viewport(800.0, 600.0);
3068        let also_medium = base.with_viewport(900.0, 600.0);
3069
3070        assert!(small.viewport_breakpoint_changed(&medium, &bps));
3071        assert!(medium.viewport_breakpoint_changed(&small, &bps));
3072        assert!(!medium.viewport_breakpoint_changed(&also_medium, &bps));
3073        assert!(!small.viewport_breakpoint_changed(&small, &bps));
3074    }
3075
3076    #[test]
3077    fn viewport_breakpoint_changed_is_exactly_on_the_boundary() {
3078        let bps = [800.0_f32];
3079        let base = DynamicSelectorContext::default();
3080        // `>=` bound: 800 is "above", 799.99 is not.
3081        let at = base.with_viewport(800.0, 600.0);
3082        let just_below = base.with_viewport(799.99, 600.0);
3083        assert!(at.viewport_breakpoint_changed(&just_below, &bps));
3084        assert!(!at.viewport_breakpoint_changed(&at, &bps));
3085    }
3086
3087    #[test]
3088    fn viewport_breakpoint_changed_handles_empty_and_degenerate_breakpoints() {
3089        let base = DynamicSelectorContext::default();
3090        let a = base.with_viewport(100.0, 100.0);
3091        let b = base.with_viewport(5000.0, 100.0);
3092        assert!(!a.viewport_breakpoint_changed(&b, &[]));
3093        // NaN breakpoints: `>=` is false on both sides -> no crossing, no panic.
3094        assert!(!a.viewport_breakpoint_changed(&b, &[f32::NAN]));
3095        // Infinite breakpoints: nothing is >= +inf, everything is >= -inf.
3096        assert!(!a.viewport_breakpoint_changed(&b, &[f32::INFINITY]));
3097        assert!(!a.viewport_breakpoint_changed(&b, &[f32::NEG_INFINITY]));
3098        // A NaN viewport is never "above" any breakpoint.
3099        let nan_vp = base.with_viewport(f32::NAN, 100.0);
3100        assert!(nan_vp.viewport_breakpoint_changed(&b, &[800.0]));
3101    }
3102
3103    #[test]
3104    fn viewport_breakpoint_changed_with_many_breakpoints_terminates() {
3105        let bps: Vec<f32> = (0..100_000).map(|i| i as f32).collect();
3106        let base = DynamicSelectorContext::default();
3107        let a = base.with_viewport(0.0, 100.0);
3108        let b = base.with_viewport(99_999.0, 100.0);
3109        assert!(a.viewport_breakpoint_changed(&b, &bps));
3110    }
3111
3112    // ---------------------------------------------------------------
3113    // 31-35. DynamicSelector matching
3114    // ---------------------------------------------------------------
3115
3116    #[test]
3117    fn match_os_any_matches_everything() {
3118        for actual in [
3119            OsCondition::Any,
3120            OsCondition::Apple,
3121            OsCondition::MacOS,
3122            OsCondition::IOS,
3123            OsCondition::Linux,
3124            OsCondition::Windows,
3125            OsCondition::Android,
3126            OsCondition::Web,
3127        ] {
3128            assert!(DynamicSelector::match_os(OsCondition::Any, actual));
3129        }
3130    }
3131
3132    #[test]
3133    fn match_os_apple_is_the_macos_ios_union() {
3134        assert!(DynamicSelector::match_os(
3135            OsCondition::Apple,
3136            OsCondition::MacOS
3137        ));
3138        assert!(DynamicSelector::match_os(
3139            OsCondition::Apple,
3140            OsCondition::IOS
3141        ));
3142        assert!(!DynamicSelector::match_os(
3143            OsCondition::Apple,
3144            OsCondition::Linux
3145        ));
3146        // Note the asymmetry: `Apple` as the *actual* OS does not satisfy `MacOS`.
3147        assert!(!DynamicSelector::match_os(
3148            OsCondition::MacOS,
3149            OsCondition::Apple
3150        ));
3151    }
3152
3153    #[test]
3154    fn match_os_concrete_conditions_require_equality() {
3155        assert!(DynamicSelector::match_os(
3156            OsCondition::Linux,
3157            OsCondition::Linux
3158        ));
3159        assert!(!DynamicSelector::match_os(
3160            OsCondition::Linux,
3161            OsCondition::Windows
3162        ));
3163        // `Any` as the *actual* OS (i.e. unknown platform) must not satisfy a concrete rule.
3164        assert!(!DynamicSelector::match_os(
3165            OsCondition::Windows,
3166            OsCondition::Any
3167        ));
3168    }
3169
3170    #[test]
3171    fn match_os_version_min_max_exact_at_zero_and_u32_max() {
3172        let zero = OsVersion::new(OsFamily::Linux, 0);
3173        let max = OsVersion::new(OsFamily::Linux, u32::MAX);
3174        let none = OptionLinuxDesktopEnv::None;
3175
3176        assert!(DynamicSelector::match_os_version(
3177            &OsVersionCondition::Min(zero),
3178            max,
3179            none,
3180            0
3181        ));
3182        assert!(!DynamicSelector::match_os_version(
3183            &OsVersionCondition::Min(max),
3184            zero,
3185            none,
3186            0
3187        ));
3188        assert!(DynamicSelector::match_os_version(
3189            &OsVersionCondition::Max(max),
3190            zero,
3191            none,
3192            0
3193        ));
3194        assert!(DynamicSelector::match_os_version(
3195            &OsVersionCondition::Exact(max),
3196            max,
3197            none,
3198            0
3199        ));
3200        assert!(!DynamicSelector::match_os_version(
3201            &OsVersionCondition::Exact(zero),
3202            max,
3203            none,
3204            0
3205        ));
3206    }
3207
3208    #[test]
3209    fn match_os_version_cross_family_never_matches() {
3210        let none = OptionLinuxDesktopEnv::None;
3211        // A Windows rule evaluated against a macOS runtime must be false for all three ops.
3212        for cond in [
3213            OsVersionCondition::Min(OsVersion::WIN_10),
3214            OsVersionCondition::Max(OsVersion::WIN_10),
3215            OsVersionCondition::Exact(OsVersion::WIN_10),
3216        ] {
3217            assert!(
3218                !DynamicSelector::match_os_version(
3219                    &cond,
3220                    OsVersion::MACOS_SONOMA,
3221                    none,
3222                    0
3223                ),
3224                "{cond:?} must not match a macOS runtime"
3225            );
3226        }
3227    }
3228
3229    #[test]
3230    fn match_os_version_desktop_environment_requires_the_env_to_be_present() {
3231        let cond = OsVersionCondition::DesktopEnvironment(LinuxDesktopEnv::Gnome);
3232        assert!(DynamicSelector::match_os_version(
3233            &cond,
3234            OsVersion::unknown(),
3235            OptionLinuxDesktopEnv::Some(LinuxDesktopEnv::Gnome),
3236            0
3237        ));
3238        assert!(!DynamicSelector::match_os_version(
3239            &cond,
3240            OsVersion::unknown(),
3241            OptionLinuxDesktopEnv::Some(LinuxDesktopEnv::KDE),
3242            0
3243        ));
3244        assert!(!DynamicSelector::match_os_version(
3245            &cond,
3246            OsVersion::unknown(),
3247            OptionLinuxDesktopEnv::None,
3248            0
3249        ));
3250    }
3251
3252    #[test]
3253    fn match_os_version_desktop_env_min_max_respect_the_unknown_zero_sentinel() {
3254        let gnome = OptionLinuxDesktopEnv::Some(LinuxDesktopEnv::Gnome);
3255        let dev = DesktopEnvVersion {
3256            env: LinuxDesktopEnv::Gnome,
3257            version_id: 40,
3258        };
3259        // de_version == 0 means "not detected": Min/Max constraints must fail.
3260        assert!(!DynamicSelector::match_os_version(
3261            &OsVersionCondition::DesktopEnvMin(dev),
3262            OsVersion::unknown(),
3263            gnome,
3264            0
3265        ));
3266        assert!(!DynamicSelector::match_os_version(
3267            &OsVersionCondition::DesktopEnvMax(dev),
3268            OsVersion::unknown(),
3269            gnome,
3270            0
3271        ));
3272        // With a real version, the bounds are inclusive.
3273        assert!(DynamicSelector::match_os_version(
3274            &OsVersionCondition::DesktopEnvMin(dev),
3275            OsVersion::unknown(),
3276            gnome,
3277            40
3278        ));
3279        assert!(DynamicSelector::match_os_version(
3280            &OsVersionCondition::DesktopEnvMin(dev),
3281            OsVersion::unknown(),
3282            gnome,
3283            u32::MAX
3284        ));
3285        assert!(!DynamicSelector::match_os_version(
3286            &OsVersionCondition::DesktopEnvMin(dev),
3287            OsVersion::unknown(),
3288            gnome,
3289            39
3290        ));
3291        assert!(DynamicSelector::match_os_version(
3292            &OsVersionCondition::DesktopEnvMax(dev),
3293            OsVersion::unknown(),
3294            gnome,
3295            40
3296        ));
3297        assert!(!DynamicSelector::match_os_version(
3298            &OsVersionCondition::DesktopEnvMax(dev),
3299            OsVersion::unknown(),
3300            gnome,
3301            41
3302        ));
3303    }
3304
3305    #[test]
3306    fn match_os_version_desktop_env_exact_needs_the_matching_env() {
3307        let dev = DesktopEnvVersion {
3308            env: LinuxDesktopEnv::Gnome,
3309            version_id: 45,
3310        };
3311        assert!(DynamicSelector::match_os_version(
3312            &OsVersionCondition::DesktopEnvExact(dev),
3313            OsVersion::unknown(),
3314            OptionLinuxDesktopEnv::Some(LinuxDesktopEnv::Gnome),
3315            45
3316        ));
3317        assert!(!DynamicSelector::match_os_version(
3318            &OsVersionCondition::DesktopEnvExact(dev),
3319            OsVersion::unknown(),
3320            OptionLinuxDesktopEnv::Some(LinuxDesktopEnv::KDE),
3321            45
3322        ));
3323        assert!(!DynamicSelector::match_os_version(
3324            &OsVersionCondition::DesktopEnvExact(dev),
3325            OsVersion::unknown(),
3326            OptionLinuxDesktopEnv::Some(LinuxDesktopEnv::Gnome),
3327            46
3328        ));
3329    }
3330
3331    // RED (genuine bug, low severity): the invariant documented directly above
3332    // `match_os_version` is "de_version == 0 means the runtime hasn't reported a version,
3333    // so any DE-version constraint fails". `DesktopEnvMin`/`DesktopEnvMax` guard on
3334    // `de_version != 0`, but `DesktopEnvExact` does not — so `@os(linux:gnome = 0)`
3335    // matches every GNOME session while DE-version detection is still unwired.
3336    #[test]
3337    fn match_os_version_desktop_env_exact_zero_fails_when_de_version_is_unknown() {
3338        let dev = DesktopEnvVersion {
3339            env: LinuxDesktopEnv::Gnome,
3340            version_id: 0,
3341        };
3342        assert!(
3343            !DynamicSelector::match_os_version(
3344                &OsVersionCondition::DesktopEnvExact(dev),
3345                OsVersion::unknown(),
3346                OptionLinuxDesktopEnv::Some(LinuxDesktopEnv::Gnome),
3347                0
3348            ),
3349            "de_version == 0 is the 'unknown' sentinel; it must not satisfy an exact match"
3350        );
3351    }
3352
3353    #[test]
3354    fn match_theme_system_preferred_is_a_wildcard() {
3355        for actual in [
3356            ThemeCondition::Light,
3357            ThemeCondition::Dark,
3358            ThemeCondition::SystemPreferred,
3359            ThemeCondition::Custom(AzString::from_const_str("solarized")),
3360        ] {
3361            assert!(DynamicSelector::match_theme(
3362                &ThemeCondition::SystemPreferred,
3363                &actual
3364            ));
3365        }
3366    }
3367
3368    #[test]
3369    fn match_theme_custom_compares_the_name() {
3370        let a = ThemeCondition::Custom(AzString::from_const_str("nord"));
3371        let b = ThemeCondition::Custom(AzString::from_const_str("nord"));
3372        let c = ThemeCondition::Custom(AzString::from_const_str("Nord"));
3373        assert!(DynamicSelector::match_theme(&a, &b));
3374        // Theme names are compared case-sensitively.
3375        assert!(!DynamicSelector::match_theme(&a, &c));
3376        assert!(!DynamicSelector::match_theme(&a, &ThemeCondition::Dark));
3377        // `SystemPreferred` as the *actual* theme does not satisfy a concrete rule.
3378        assert!(!DynamicSelector::match_theme(
3379            &ThemeCondition::Dark,
3380            &ThemeCondition::SystemPreferred
3381        ));
3382    }
3383
3384    #[test]
3385    fn match_pseudo_state_reads_through_to_the_context_flags() {
3386        let ctx = DynamicSelectorContext::default().with_pseudo_state(PseudoStateFlags {
3387            hover: true,
3388            checked: true,
3389            ..PseudoStateFlags::default()
3390        });
3391        assert!(DynamicSelector::match_pseudo_state(
3392            PseudoStateType::Hover,
3393            &ctx
3394        ));
3395        assert!(DynamicSelector::match_pseudo_state(
3396            PseudoStateType::CheckedTrue,
3397            &ctx
3398        ));
3399        assert!(!DynamicSelector::match_pseudo_state(
3400            PseudoStateType::CheckedFalse,
3401            &ctx
3402        ));
3403        assert!(!DynamicSelector::match_pseudo_state(
3404            PseudoStateType::Active,
3405            &ctx
3406        ));
3407        assert!(DynamicSelector::match_pseudo_state(
3408            PseudoStateType::Normal,
3409            &ctx
3410        ));
3411    }
3412
3413    #[test]
3414    fn match_pseudo_state_agrees_with_has_state_for_every_state() {
3415        let flags = PseudoStateFlags {
3416            hover: true,
3417            focused: true,
3418            visited: true,
3419            drag_over: true,
3420            ..PseudoStateFlags::default()
3421        };
3422        let ctx = DynamicSelectorContext::default().with_pseudo_state(flags);
3423        for state in [
3424            PseudoStateType::Normal,
3425            PseudoStateType::Hover,
3426            PseudoStateType::Active,
3427            PseudoStateType::Focus,
3428            PseudoStateType::Disabled,
3429            PseudoStateType::CheckedTrue,
3430            PseudoStateType::CheckedFalse,
3431            PseudoStateType::FocusWithin,
3432            PseudoStateType::Visited,
3433            PseudoStateType::Backdrop,
3434            PseudoStateType::Dragging,
3435            PseudoStateType::DragOver,
3436        ] {
3437            assert_eq!(
3438                DynamicSelector::match_pseudo_state(state, &ctx),
3439                flags.has_state(state),
3440                "match_pseudo_state and has_state disagree on {state:?}"
3441            );
3442        }
3443    }
3444
3445    #[test]
3446    fn selector_matches_every_variant_against_the_default_context() {
3447        // Smoke: no variant may panic on the default context.
3448        let ctx = DynamicSelectorContext::default();
3449        for sel in all_selector_variants() {
3450            let a = sel.matches(&ctx);
3451            let b = sel.matches(&ctx);
3452            assert_eq!(a, b, "{sel:?} is not deterministic");
3453        }
3454    }
3455
3456    #[test]
3457    fn selector_matches_media_all_is_a_wildcard() {
3458        let ctx = DynamicSelectorContext::default();
3459        assert_eq!(ctx.media_type, MediaType::Screen);
3460        assert!(DynamicSelector::Media(MediaType::All).matches(&ctx));
3461        assert!(DynamicSelector::Media(MediaType::Screen).matches(&ctx));
3462        assert!(!DynamicSelector::Media(MediaType::Print).matches(&ctx));
3463    }
3464
3465    #[test]
3466    fn selector_matches_aspect_ratio_never_divides_by_zero() {
3467        let base = DynamicSelectorContext::default();
3468        // height 0 is clamped to 1.0 by `.max(1.0)`, so the ratio stays finite.
3469        let flat = base.with_viewport(800.0, 0.0);
3470        assert!(DynamicSelector::AspectRatio(MinMaxRange::new(Some(799.0), Some(801.0)))
3471            .matches(&flat));
3472        // NaN height also clamps to 1.0 (f32::max ignores NaN).
3473        let nan_h = base.with_viewport(800.0, f32::NAN);
3474        assert!(DynamicSelector::AspectRatio(MinMaxRange::new(Some(799.0), Some(801.0)))
3475            .matches(&nan_h));
3476        // A NaN *width* yields a NaN ratio, which any real bound rejects.
3477        let nan_w = base.with_viewport(f32::NAN, 600.0);
3478        assert!(!DynamicSelector::AspectRatio(MinMaxRange::with_min(0.0)).matches(&nan_w));
3479    }
3480
3481    #[test]
3482    fn selector_matches_container_name_requires_an_exact_name() {
3483        let ctx = DynamicSelectorContext::default().with_container(
3484            100.0,
3485            100.0,
3486            Some(AzString::from_const_str("sidebar")),
3487        );
3488        assert!(
3489            DynamicSelector::ContainerName(AzString::from_const_str("sidebar")).matches(&ctx)
3490        );
3491        assert!(
3492            !DynamicSelector::ContainerName(AzString::from_const_str("Sidebar")).matches(&ctx)
3493        );
3494        assert!(!DynamicSelector::ContainerName(AzString::from_const_str("main")).matches(&ctx));
3495        // No container at all.
3496        let no_ctr = DynamicSelectorContext::default();
3497        assert!(
3498            !DynamicSelector::ContainerName(AzString::from_const_str("sidebar")).matches(&no_ctr)
3499        );
3500    }
3501
3502    #[test]
3503    fn selector_matches_bool_conditions_compare_both_polarities() {
3504        let ctx = DynamicSelectorContext::default();
3505        assert_eq!(ctx.prefers_reduced_motion, BoolCondition::False);
3506        // The impl compares equality, so `False` matches a "no preference" runtime.
3507        assert!(DynamicSelector::PrefersReducedMotion(BoolCondition::False).matches(&ctx));
3508        assert!(!DynamicSelector::PrefersReducedMotion(BoolCondition::True).matches(&ctx));
3509        assert!(DynamicSelector::PrefersHighContrast(BoolCondition::False).matches(&ctx));
3510        assert!(!DynamicSelector::PrefersHighContrast(BoolCondition::True).matches(&ctx));
3511    }
3512
3513    #[test]
3514    fn bool_condition_roundtrips_through_bool() {
3515        for b in [false, true] {
3516            assert_eq!(bool::from(BoolCondition::from(b)), b);
3517        }
3518        assert_eq!(BoolCondition::default(), BoolCondition::False);
3519        assert!(!bool::from(BoolCondition::False));
3520        assert!(bool::from(BoolCondition::True));
3521    }
3522
3523    // ---------------------------------------------------------------
3524    // 40-57. CssPropertyWithConditions
3525    // ---------------------------------------------------------------
3526
3527    #[test]
3528    fn simple_property_is_unconditional_and_always_matches() {
3529        let p = CssPropertyWithConditions::simple(paint_prop());
3530        assert!(p.apply_if.as_slice().is_empty());
3531        assert!(!p.is_conditional());
3532        assert!(!p.is_pseudo_state_only());
3533        assert!(p.matches(&DynamicSelectorContext::default()));
3534    }
3535
3536    #[test]
3537    fn with_condition_and_with_single_condition_agree() {
3538        let a = CssPropertyWithConditions::with_condition(
3539            paint_prop(),
3540            DynamicSelector::PseudoState(PseudoStateType::Hover),
3541        );
3542        let b = CssPropertyWithConditions::on_hover(paint_prop());
3543        assert_eq!(a.apply_if.as_slice(), b.apply_if.as_slice());
3544        assert_eq!(a.apply_if.as_slice().len(), 1);
3545    }
3546
3547    #[test]
3548    fn with_conditions_preserves_order_and_length() {
3549        let conds = all_selector_variants();
3550        let p = CssPropertyWithConditions::with_conditions(
3551            paint_prop(),
3552            DynamicSelectorVec::from_vec(conds.clone()),
3553        );
3554        assert_eq!(p.apply_if.as_slice().len(), conds.len());
3555        for (i, c) in conds.iter().enumerate() {
3556            assert_eq!(p.apply_if.as_slice()[i].variant_tag(), c.variant_tag());
3557        }
3558        assert!(p.is_conditional());
3559    }
3560
3561    #[test]
3562    fn with_conditions_empty_vec_is_unconditional() {
3563        let p = CssPropertyWithConditions::with_conditions(
3564            paint_prop(),
3565            DynamicSelectorVec::from_vec(vec![]),
3566        );
3567        assert!(!p.is_conditional());
3568        assert!(p.matches(&DynamicSelectorContext::default()));
3569    }
3570
3571    #[test]
3572    fn pseudo_state_constructors_build_the_right_condition() {
3573        let cases = [
3574            (
3575                CssPropertyWithConditions::on_hover(paint_prop()),
3576                PseudoStateType::Hover,
3577            ),
3578            (
3579                CssPropertyWithConditions::on_active(paint_prop()),
3580                PseudoStateType::Active,
3581            ),
3582            (
3583                CssPropertyWithConditions::on_focus(paint_prop()),
3584                PseudoStateType::Focus,
3585            ),
3586            (
3587                CssPropertyWithConditions::when_disabled(paint_prop()),
3588                PseudoStateType::Disabled,
3589            ),
3590        ];
3591        for (prop, expected) in cases {
3592            assert_eq!(
3593                prop.apply_if.as_slice(),
3594                &[DynamicSelector::PseudoState(expected)]
3595            );
3596            assert!(prop.is_pseudo_state_only());
3597            assert!(prop.is_conditional());
3598        }
3599    }
3600
3601    #[test]
3602    fn os_and_theme_constructors_build_the_right_condition() {
3603        assert_eq!(
3604            CssPropertyWithConditions::on_windows(paint_prop())
3605                .apply_if
3606                .as_slice(),
3607            &[DynamicSelector::Os(OsCondition::Windows)]
3608        );
3609        assert_eq!(
3610            CssPropertyWithConditions::on_macos(paint_prop())
3611                .apply_if
3612                .as_slice(),
3613            &[DynamicSelector::Os(OsCondition::MacOS)]
3614        );
3615        assert_eq!(
3616            CssPropertyWithConditions::on_linux(paint_prop())
3617                .apply_if
3618                .as_slice(),
3619            &[DynamicSelector::Os(OsCondition::Linux)]
3620        );
3621        assert_eq!(
3622            CssPropertyWithConditions::on_os(paint_prop(), OsCondition::Web)
3623                .apply_if
3624                .as_slice(),
3625            &[DynamicSelector::Os(OsCondition::Web)]
3626        );
3627        assert_eq!(
3628            CssPropertyWithConditions::dark_theme(paint_prop())
3629                .apply_if
3630                .as_slice(),
3631            &[DynamicSelector::Theme(ThemeCondition::Dark)]
3632        );
3633        assert_eq!(
3634            CssPropertyWithConditions::light_theme(paint_prop())
3635                .apply_if
3636                .as_slice(),
3637            &[DynamicSelector::Theme(ThemeCondition::Light)]
3638        );
3639        // OS / theme conditions are not pseudo-state conditions.
3640        assert!(!CssPropertyWithConditions::on_linux(paint_prop()).is_pseudo_state_only());
3641        assert!(!CssPropertyWithConditions::dark_theme(paint_prop()).is_pseudo_state_only());
3642    }
3643
3644    #[test]
3645    fn matches_requires_all_conditions_to_hold() {
3646        let ctx = DynamicSelectorContext::default().with_pseudo_state(PseudoStateFlags {
3647            hover: true,
3648            ..PseudoStateFlags::default()
3649        });
3650        // hover (true) AND focus (false) -> false.
3651        let both = CssPropertyWithConditions::with_conditions(
3652            paint_prop(),
3653            DynamicSelectorVec::from_vec(vec![
3654                DynamicSelector::PseudoState(PseudoStateType::Hover),
3655                DynamicSelector::PseudoState(PseudoStateType::Focus),
3656            ]),
3657        );
3658        assert!(!both.matches(&ctx));
3659
3660        // hover (true) AND normal (always true) -> true.
3661        let ok = CssPropertyWithConditions::with_conditions(
3662            paint_prop(),
3663            DynamicSelectorVec::from_vec(vec![
3664                DynamicSelector::PseudoState(PseudoStateType::Hover),
3665                DynamicSelector::PseudoState(PseudoStateType::Normal),
3666            ]),
3667        );
3668        assert!(ok.matches(&ctx));
3669    }
3670
3671    #[test]
3672    fn matches_with_a_large_condition_list_terminates() {
3673        let conds: Vec<DynamicSelector> = (0..50_000)
3674            .map(|_| DynamicSelector::PseudoState(PseudoStateType::Normal))
3675            .collect();
3676        let p = CssPropertyWithConditions::with_conditions(
3677            paint_prop(),
3678            DynamicSelectorVec::from_vec(conds),
3679        );
3680        assert!(p.matches(&DynamicSelectorContext::default()));
3681    }
3682
3683    #[test]
3684    fn is_pseudo_state_only_is_false_for_empty_and_for_mixed_lists() {
3685        // Empty -> false (there is no pseudo-state condition at all).
3686        assert!(!CssPropertyWithConditions::simple(paint_prop()).is_pseudo_state_only());
3687        // Mixed -> false.
3688        let mixed = CssPropertyWithConditions::with_conditions(
3689            paint_prop(),
3690            DynamicSelectorVec::from_vec(vec![
3691                DynamicSelector::PseudoState(PseudoStateType::Hover),
3692                DynamicSelector::Os(OsCondition::Linux),
3693            ]),
3694        );
3695        assert!(!mixed.is_pseudo_state_only());
3696        // All pseudo -> true.
3697        let all_pseudo = CssPropertyWithConditions::with_conditions(
3698            paint_prop(),
3699            DynamicSelectorVec::from_vec(vec![
3700                DynamicSelector::PseudoState(PseudoStateType::Hover),
3701                DynamicSelector::PseudoState(PseudoStateType::Focus),
3702            ]),
3703        );
3704        assert!(all_pseudo.is_pseudo_state_only());
3705    }
3706
3707    #[test]
3708    fn is_layout_affecting_splits_layout_from_paint() {
3709        assert!(CssPropertyWithConditions::simple(layout_prop()).is_layout_affecting());
3710        assert!(!CssPropertyWithConditions::simple(paint_prop()).is_layout_affecting());
3711        // Conditions must not influence the answer — only the property does.
3712        assert!(CssPropertyWithConditions::on_hover(layout_prop()).is_layout_affecting());
3713        assert!(!CssPropertyWithConditions::on_hover(paint_prop()).is_layout_affecting());
3714    }
3715
3716    #[test]
3717    fn css_property_with_conditions_hash_agrees_with_eq() {
3718        let a = CssPropertyWithConditions::on_hover(paint_prop());
3719        let b = CssPropertyWithConditions::on_hover(paint_prop());
3720        assert_eq!(a, b);
3721        assert_eq!(hash_of(&a), hash_of(&b));
3722        assert_eq!(a.cmp(&b), Ordering::Equal);
3723
3724        // Same property, different condition sets must not collide on the hash and
3725        // must not compare Equal (the old impl keyed on condition *count* only).
3726        let c = CssPropertyWithConditions::on_focus(paint_prop());
3727        assert_ne!(a, c);
3728        assert_ne!(a.cmp(&c), Ordering::Equal);
3729        assert_ne!(hash_of(&a), hash_of(&c));
3730    }
3731
3732    #[test]
3733    fn css_property_with_conditions_ord_is_lexicographic_over_conditions() {
3734        let short = CssPropertyWithConditions::on_hover(paint_prop());
3735        let long = CssPropertyWithConditions::with_conditions(
3736            paint_prop(),
3737            DynamicSelectorVec::from_vec(vec![
3738                DynamicSelector::PseudoState(PseudoStateType::Hover),
3739                DynamicSelector::PseudoState(PseudoStateType::Focus),
3740            ]),
3741        );
3742        // A prefix sorts before the longer list.
3743        assert_eq!(short.cmp(&long), Ordering::Less);
3744        assert_eq!(long.cmp(&short), Ordering::Greater);
3745    }
3746
3747    // ---------------------------------------------------------------
3748    // 36-39. @os at-rule parsing (parser feature)
3749    // ---------------------------------------------------------------
3750
3751    #[cfg(feature = "parser")]
3752    #[test]
3753    fn parse_os_at_rule_bare_and_parenthesized_forms_agree() {
3754        let bare = parse_os_at_rule_content("linux").expect("bare linux");
3755        let paren = parse_os_at_rule_content("(linux)").expect("(linux)");
3756        let quoted = parse_os_at_rule_content("(\"linux\")").expect("quoted");
3757        let spaced = parse_os_at_rule_content("   (  linux  )   ").expect("spaced");
3758        assert_eq!(bare, vec![DynamicSelector::Os(OsCondition::Linux)]);
3759        assert_eq!(bare, paren);
3760        assert_eq!(bare, quoted);
3761        assert_eq!(bare, spaced);
3762    }
3763
3764    #[cfg(feature = "parser")]
3765    #[test]
3766    fn parse_os_at_rule_emits_the_family_even_for_any() {
3767        // `(any)` still emits `Os(Any)` (documented: kept for introspection), and
3768        // `Os(Any)` matches unconditionally.
3769        for s in ["(any)", "(all)", "(*)"] {
3770            let conds = parse_os_at_rule_content(s).unwrap_or_else(|| panic!("{s} must parse"));
3771            assert_eq!(conds, vec![DynamicSelector::Os(OsCondition::Any)]);
3772            assert!(conds[0].matches(&DynamicSelectorContext::default()));
3773        }
3774    }
3775
3776    #[cfg(feature = "parser")]
3777    #[test]
3778    fn parse_os_at_rule_desktop_env_forms() {
3779        assert_eq!(
3780            parse_os_at_rule_content("(linux:gnome)"),
3781            Some(vec![
3782                DynamicSelector::Os(OsCondition::Linux),
3783                DynamicSelector::OsVersion(OsVersionCondition::DesktopEnvironment(
3784                    LinuxDesktopEnv::Gnome
3785                )),
3786            ])
3787        );
3788        // Unknown DE names silently become `Other` (documented `parse_de_token` fallback).
3789        assert_eq!(
3790            parse_os_at_rule_content("(linux:notarealde)"),
3791            Some(vec![
3792                DynamicSelector::Os(OsCondition::Linux),
3793                DynamicSelector::OsVersion(OsVersionCondition::DesktopEnvironment(
3794                    LinuxDesktopEnv::Other
3795                )),
3796            ])
3797        );
3798        // A trailing ':' with no DE is treated as "no DE".
3799        assert_eq!(
3800            parse_os_at_rule_content("(linux:)"),
3801            Some(vec![DynamicSelector::Os(OsCondition::Linux)])
3802        );
3803    }
3804
3805    #[cfg(feature = "parser")]
3806    #[test]
3807    fn parse_os_at_rule_version_operators() {
3808        assert_eq!(
3809            parse_os_at_rule_content("(windows >= win-11)"),
3810            Some(vec![
3811                DynamicSelector::Os(OsCondition::Windows),
3812                DynamicSelector::OsVersion(OsVersionCondition::Min(OsVersion::WIN_11)),
3813            ])
3814        );
3815        // `>` is documented to behave as `>=` (version ids are discrete).
3816        assert_eq!(
3817            parse_os_at_rule_content("(windows > win-11)"),
3818            parse_os_at_rule_content("(windows >= win-11)")
3819        );
3820        assert_eq!(
3821            parse_os_at_rule_content("(macos <= sonoma)"),
3822            Some(vec![
3823                DynamicSelector::Os(OsCondition::MacOS),
3824                DynamicSelector::OsVersion(OsVersionCondition::Max(OsVersion::MACOS_SONOMA)),
3825            ])
3826        );
3827        assert_eq!(
3828            parse_os_at_rule_content("(ios = 17)"),
3829            Some(vec![
3830                DynamicSelector::Os(OsCondition::IOS),
3831                DynamicSelector::OsVersion(OsVersionCondition::Exact(OsVersion::IOS_17)),
3832            ])
3833        );
3834    }
3835
3836    #[cfg(feature = "parser")]
3837    #[test]
3838    fn parse_os_at_rule_desktop_env_version() {
3839        assert_eq!(
3840            parse_os_at_rule_content("(linux:gnome > 40)"),
3841            Some(vec![
3842                DynamicSelector::Os(OsCondition::Linux),
3843                DynamicSelector::OsVersion(OsVersionCondition::DesktopEnvMin(
3844                    DesktopEnvVersion {
3845                        env: LinuxDesktopEnv::Gnome,
3846                        version_id: 40,
3847                    }
3848                )),
3849            ])
3850        );
3851        // DE version must be a plain u32: overflow and junk are rejected, not wrapped.
3852        assert_eq!(parse_os_at_rule_content("(linux:gnome > 4294967296)"), None);
3853        assert_eq!(parse_os_at_rule_content("(linux:gnome > -1)"), None);
3854        assert_eq!(parse_os_at_rule_content("(linux:gnome > abc)"), None);
3855        assert_eq!(parse_os_at_rule_content("(linux:gnome > )"), None);
3856    }
3857
3858    #[cfg(feature = "parser")]
3859    #[test]
3860    fn parse_os_at_rule_rejects_versions_on_versionless_families() {
3861        // Apple / Web / Any have no version line -> reject rather than guess.
3862        assert_eq!(parse_os_at_rule_content("(apple >= 14)"), None);
3863        assert_eq!(parse_os_at_rule_content("(web >= 1)"), None);
3864        assert_eq!(parse_os_at_rule_content("(any >= 1)"), None);
3865    }
3866
3867    #[cfg(feature = "parser")]
3868    #[test]
3869    fn parse_os_at_rule_empty_and_garbage_is_none() {
3870        for s in [
3871            "",
3872            "   ",
3873            "\t\n",
3874            "()",
3875            "(   )",
3876            "(\"\")",
3877            "('')",
3878            "(:)",
3879            "(:gnome)",
3880            "(notanos)",
3881            "(linux;drop)",
3882            "\u{1F600}",
3883            "(\u{1F600})",
3884            "(linux linux)",
3885        ] {
3886            assert_eq!(parse_os_at_rule_content(s), None, "{s:?} must not parse");
3887        }
3888    }
3889
3890    #[cfg(feature = "parser")]
3891    #[test]
3892    fn parse_os_at_rule_huge_and_deeply_parenthesized_input_terminates() {
3893        // Only one layer of parens is stripped; the rest is junk -> None, no hang.
3894        let nested = format!("{}linux{}", "(".repeat(10_000), ")".repeat(10_000));
3895        assert_eq!(parse_os_at_rule_content(&nested), None);
3896        assert_eq!(parse_os_at_rule_content(&"a".repeat(1_000_000)), None);
3897        assert_eq!(
3898            parse_os_at_rule_content(&format!("(linux >= {})", "9".repeat(100_000))),
3899            None
3900        );
3901    }
3902
3903    #[cfg(feature = "parser")]
3904    #[test]
3905    fn split_op_and_version_picks_the_earliest_then_longest_operator() {
3906        let (subject, op) = split_op_and_version("linux >= 6.0");
3907        assert_eq!(subject, "linux ");
3908        let (op, ver) = op.expect("operator found");
3909        assert!(matches!(op, VersionOp::Min));
3910        assert_eq!(ver, "6.0");
3911
3912        // ">=" must beat "=" at the same position.
3913        let (_, op) = split_op_and_version("a>=1");
3914        assert!(matches!(op.expect("op").0, VersionOp::Min));
3915        let (_, op) = split_op_and_version("a<=1");
3916        assert!(matches!(op.expect("op").0, VersionOp::Max));
3917        let (_, op) = split_op_and_version("a=1");
3918        assert!(matches!(op.expect("op").0, VersionOp::Exact));
3919    }
3920
3921    #[cfg(feature = "parser")]
3922    #[test]
3923    fn split_op_and_version_with_no_operator_returns_the_whole_string() {
3924        let (subject, op) = split_op_and_version("linux");
3925        assert_eq!(subject, "linux");
3926        assert!(op.is_none());
3927
3928        let (subject, op) = split_op_and_version("");
3929        assert_eq!(subject, "");
3930        assert!(op.is_none());
3931    }
3932
3933    #[cfg(feature = "parser")]
3934    #[test]
3935    fn split_op_and_version_operator_only_yields_empty_sides() {
3936        let (subject, op) = split_op_and_version(">=");
3937        assert_eq!(subject, "");
3938        assert_eq!(op.expect("op").1, "");
3939
3940        let (subject, op) = split_op_and_version("=");
3941        assert_eq!(subject, "");
3942        assert_eq!(op.expect("op").1, "");
3943    }
3944
3945    #[cfg(feature = "parser")]
3946    #[test]
3947    fn split_op_and_version_does_not_split_inside_a_multibyte_char() {
3948        // Operators are ASCII, so the byte offsets returned by `find` are always char
3949        // boundaries — but assert it, because slicing here would panic otherwise.
3950        let (subject, op) = split_op_and_version("日本語 >= 6.0");
3951        assert_eq!(subject, "日本語 ");
3952        assert_eq!(op.expect("op").1, "6.0");
3953        // No operator at all in a multibyte string.
3954        let (subject, op) = split_op_and_version("🦀🦀🦀");
3955        assert_eq!(subject, "🦀🦀🦀");
3956        assert!(op.is_none());
3957    }
3958
3959    #[cfg(feature = "parser")]
3960    #[test]
3961    fn parse_os_family_token_accepts_aliases_case_insensitively() {
3962        assert_eq!(parse_os_family_token("LINUX"), Some(OsCondition::Linux));
3963        assert_eq!(parse_os_family_token("Win"), Some(OsCondition::Windows));
3964        assert_eq!(parse_os_family_token("windows"), Some(OsCondition::Windows));
3965        assert_eq!(parse_os_family_token("osx"), Some(OsCondition::MacOS));
3966        assert_eq!(parse_os_family_token("mac"), Some(OsCondition::MacOS));
3967        assert_eq!(parse_os_family_token("wasm"), Some(OsCondition::Web));
3968        assert_eq!(parse_os_family_token("*"), Some(OsCondition::Any));
3969        assert_eq!(parse_os_family_token("all"), Some(OsCondition::Any));
3970    }
3971
3972    #[cfg(feature = "parser")]
3973    #[test]
3974    fn parse_os_family_token_rejects_empty_padded_and_junk() {
3975        // The token is *not* trimmed here — the caller trims.
3976        for s in [
3977            "",
3978            " ",
3979            " linux",
3980            "linux ",
3981            "lin",
3982            "linux2",
3983            "0",
3984            "-1",
3985            "NaN",
3986            "\u{1F600}",
3987        ] {
3988            assert_eq!(parse_os_family_token(s), None, "{s:?} must not parse");
3989        }
3990        assert_eq!(parse_os_family_token(&"linux".repeat(100_000)), None);
3991    }
3992
3993    #[cfg(feature = "parser")]
3994    #[test]
3995    fn parse_de_token_is_total_and_falls_back_to_other() {
3996        assert_eq!(parse_de_token("GNOME"), LinuxDesktopEnv::Gnome);
3997        assert_eq!(parse_de_token("kde"), LinuxDesktopEnv::KDE);
3998        assert_eq!(parse_de_token("XFCE"), LinuxDesktopEnv::XFCE);
3999        assert_eq!(parse_de_token("unity"), LinuxDesktopEnv::Unity);
4000        assert_eq!(parse_de_token("Cinnamon"), LinuxDesktopEnv::Cinnamon);
4001        assert_eq!(parse_de_token("mate"), LinuxDesktopEnv::MATE);
4002        // `parse_de_token` returns a value, not an Option: everything else is `Other`.
4003        for s in ["", "  ", "gnome ", "\u{1F600}", "日本語", "\0"] {
4004            assert_eq!(parse_de_token(s), LinuxDesktopEnv::Other, "{s:?}");
4005        }
4006        assert_eq!(
4007            parse_de_token(&"gnome".repeat(200_000)),
4008            LinuxDesktopEnv::Other
4009        );
4010    }
4011
4012    // ---------------------------------------------------------------
4013    // 58-65. CssPropertyWithConditionsVec parsing (parser feature)
4014    // ---------------------------------------------------------------
4015
4016    #[cfg(feature = "parser")]
4017    fn parse_len(style: &str) -> usize {
4018        CssPropertyWithConditionsVec::parse(style)
4019            .into_library_owned_vec()
4020            .len()
4021    }
4022
4023    #[cfg(feature = "parser")]
4024    #[test]
4025    fn parse_valid_minimal_positive_control() {
4026        let props = CssPropertyWithConditionsVec::parse("color: red;").into_library_owned_vec();
4027        assert_eq!(props.len(), 1);
4028        assert!(!props[0].is_conditional());
4029        assert!(matches!(props[0].property, CssProperty::TextColor(_)));
4030    }
4031
4032    #[cfg(feature = "parser")]
4033    #[test]
4034    fn parse_empty_and_whitespace_yields_no_properties() {
4035        for s in ["", " ", "\t\n\r ", ";", ";;;;", "   ;   ;   "] {
4036            assert_eq!(parse_len(s), 0, "{s:?} must yield no properties");
4037        }
4038    }
4039
4040    #[cfg(feature = "parser")]
4041    #[test]
4042    fn parse_garbage_never_panics_and_yields_nothing() {
4043        for s in [
4044            "not css at all",
4045            "color",
4046            "color:",
4047            ":",
4048            "::::",
4049            "{}",
4050            "}{",
4051            "}",
4052            "{",
4053            "{{{",
4054            "}}}",
4055            "}{color: red}",
4056            "color: ;",
4057            "\0: \0;",
4058            "%s%n%s",
4059            "\u{1F600}: \u{1F600};",
4060            "日本語: 赤;",
4061        ] {
4062            // Must terminate and not panic; the value itself is allowed to be empty.
4063            let _ = parse_len(s);
4064        }
4065        assert_eq!(parse_len("not css at all"), 0);
4066        assert_eq!(parse_len("color:"), 0);
4067        assert_eq!(parse_len("}{"), 0);
4068    }
4069
4070    #[cfg(feature = "parser")]
4071    #[test]
4072    fn parse_tolerates_a_missing_trailing_semicolon() {
4073        assert_eq!(parse_len("color: red"), 1);
4074        assert_eq!(parse_len("color: red;color: blue"), 2);
4075    }
4076
4077    #[cfg(feature = "parser")]
4078    #[test]
4079    fn parse_pseudo_selector_block_attaches_the_condition() {
4080        let props = CssPropertyWithConditionsVec::parse(":hover { color: red; }")
4081            .into_library_owned_vec();
4082        assert_eq!(props.len(), 1);
4083        assert!(props[0].is_pseudo_state_only());
4084        assert_eq!(
4085            props[0].apply_if.as_slice(),
4086            &[DynamicSelector::PseudoState(PseudoStateType::Hover)]
4087        );
4088    }
4089
4090    #[cfg(feature = "parser")]
4091    #[test]
4092    fn parse_unknown_selector_block_is_dropped_wholesale() {
4093        // An unknown pseudo-class must drop the whole block, not leak its properties
4094        // as unconditional.
4095        assert_eq!(parse_len(":nosuchstate { color: red; }"), 0);
4096        assert_eq!(parse_len("@nosuchrule { color: red; }"), 0);
4097        assert_eq!(parse_len("div { color: red; }"), 0);
4098    }
4099
4100    #[cfg(feature = "parser")]
4101    #[test]
4102    fn parse_nesting_accumulates_inherited_conditions() {
4103        let props =
4104            CssPropertyWithConditionsVec::parse("@os linux { font-size: 14px; :hover { color: red; }}")
4105                .into_library_owned_vec();
4106        assert_eq!(props.len(), 2);
4107        // Both properties carry the @os condition; the hover one carries both.
4108        let font = props
4109            .iter()
4110            .find(|p| matches!(p.property, CssProperty::FontSize(_)))
4111            .expect("font-size present");
4112        assert_eq!(
4113            font.apply_if.as_slice(),
4114            &[DynamicSelector::Os(OsCondition::Linux)]
4115        );
4116        let color = props
4117            .iter()
4118            .find(|p| matches!(p.property, CssProperty::TextColor(_)))
4119            .expect("color present");
4120        assert_eq!(
4121            color.apply_if.as_slice(),
4122            &[
4123                DynamicSelector::Os(OsCondition::Linux),
4124                DynamicSelector::PseudoState(PseudoStateType::Hover),
4125            ]
4126        );
4127        assert!(!color.is_pseudo_state_only());
4128    }
4129
4130    #[cfg(feature = "parser")]
4131    #[test]
4132    fn parse_moderately_deep_nesting_terminates() {
4133        // NOTE: `parse_block_segment` recurses once per nesting level with no depth cap,
4134        // so a pathologically nested stylesheet (~10k levels) would abort the process on
4135        // a stack overflow. Kept at a depth that is safe to run in-process; the missing
4136        // depth limit is reported separately.
4137        const DEPTH: usize = 50;
4138        let style = format!(
4139            "{}color: red;{}",
4140            ":hover {".repeat(DEPTH),
4141            "}".repeat(DEPTH)
4142        );
4143        let props = CssPropertyWithConditionsVec::parse(&style).into_library_owned_vec();
4144        assert_eq!(props.len(), 1);
4145        assert_eq!(props[0].apply_if.as_slice().len(), DEPTH);
4146        assert!(props[0].is_pseudo_state_only());
4147    }
4148
4149    #[cfg(feature = "parser")]
4150    #[test]
4151    fn parse_unbalanced_braces_do_not_panic() {
4152        // brace_depth goes negative / never returns to zero; both paths must be inert.
4153        for s in [
4154            "color: red; }",
4155            "{ color: red;",
4156            ":hover { color: red;",
4157            ":hover }",
4158            &"{".repeat(1_000),
4159            &"}".repeat(1_000),
4160        ] {
4161            let _ = parse_len(s);
4162        }
4163    }
4164
4165    #[cfg(feature = "parser")]
4166    #[test]
4167    fn parse_very_long_input_terminates() {
4168        let long = "color: red;".repeat(5_000);
4169        assert_eq!(parse_len(&long), 5_000);
4170        // A single enormous junk token must not blow up either.
4171        assert_eq!(parse_len(&"a".repeat(500_000)), 0);
4172        assert_eq!(parse_len(&format!("color: {};", "z".repeat(500_000))), 0);
4173    }
4174
4175    #[cfg(feature = "parser")]
4176    #[test]
4177    fn parse_selector_to_conditions_covers_every_pseudo_class() {
4178        let cases = [
4179            ("hover", PseudoStateType::Hover),
4180            ("active", PseudoStateType::Active),
4181            ("focus", PseudoStateType::Focus),
4182            ("focus-within", PseudoStateType::FocusWithin),
4183            ("disabled", PseudoStateType::Disabled),
4184            ("checked", PseudoStateType::CheckedTrue),
4185            ("visited", PseudoStateType::Visited),
4186            ("backdrop", PseudoStateType::Backdrop),
4187            ("dragging", PseudoStateType::Dragging),
4188            ("drag-over", PseudoStateType::DragOver),
4189        ];
4190        for (name, expected) in cases {
4191            assert_eq!(
4192                CssPropertyWithConditionsVec::parse_selector_to_conditions(&format!(":{name}")),
4193                Some(vec![DynamicSelector::PseudoState(expected)]),
4194                ":{name} must parse"
4195            );
4196        }
4197    }
4198
4199    #[cfg(feature = "parser")]
4200    #[test]
4201    fn parse_selector_to_conditions_wildcards_are_unconditional() {
4202        assert_eq!(
4203            CssPropertyWithConditionsVec::parse_selector_to_conditions("*"),
4204            Some(vec![])
4205        );
4206        assert_eq!(
4207            CssPropertyWithConditionsVec::parse_selector_to_conditions(""),
4208            Some(vec![])
4209        );
4210        assert_eq!(
4211            CssPropertyWithConditionsVec::parse_selector_to_conditions("   "),
4212            Some(vec![])
4213        );
4214    }
4215
4216    #[cfg(feature = "parser")]
4217    #[test]
4218    fn parse_selector_to_conditions_rejects_unknown_selectors() {
4219        for s in [
4220            ":", ":hoverr", ":HOVER", "div", "#id", ".class", "@", "\u{1F600}", ":\u{1F600}",
4221        ] {
4222            assert_eq!(
4223                CssPropertyWithConditionsVec::parse_selector_to_conditions(s),
4224                None,
4225                "{s:?} must not parse"
4226            );
4227        }
4228    }
4229
4230    #[cfg(feature = "parser")]
4231    #[test]
4232    fn parse_at_rule_theme_lang_and_accessibility() {
4233        assert_eq!(
4234            CssPropertyWithConditionsVec::parse_at_rule("theme dark"),
4235            Some(vec![DynamicSelector::Theme(ThemeCondition::Dark)])
4236        );
4237        assert_eq!(
4238            CssPropertyWithConditionsVec::parse_at_rule("theme light"),
4239            Some(vec![DynamicSelector::Theme(ThemeCondition::Light)])
4240        );
4241        assert_eq!(CssPropertyWithConditionsVec::parse_at_rule("theme neon"), None);
4242
4243        assert_eq!(
4244            CssPropertyWithConditionsVec::parse_at_rule("lang(\"de-DE\")"),
4245            Some(vec![DynamicSelector::Language(LanguageCondition::Prefix(
4246                AzString::from_const_str("de-DE")
4247            ))])
4248        );
4249        assert_eq!(
4250            CssPropertyWithConditionsVec::parse_at_rule("lang de"),
4251            Some(vec![DynamicSelector::Language(LanguageCondition::Prefix(
4252                AzString::from_const_str("de")
4253            ))])
4254        );
4255        assert_eq!(CssPropertyWithConditionsVec::parse_at_rule("lang()"), None);
4256        assert_eq!(CssPropertyWithConditionsVec::parse_at_rule("lang(\"\")"), None);
4257
4258        assert_eq!(
4259            CssPropertyWithConditionsVec::parse_at_rule("prefers-reduced-motion"),
4260            Some(vec![DynamicSelector::PrefersReducedMotion(
4261                BoolCondition::True
4262            )])
4263        );
4264        assert_eq!(
4265            CssPropertyWithConditionsVec::parse_at_rule("high-contrast"),
4266            Some(vec![DynamicSelector::PrefersHighContrast(
4267                BoolCondition::True
4268            )])
4269        );
4270    }
4271
4272    #[cfg(feature = "parser")]
4273    #[test]
4274    fn parse_at_rule_container_named_and_sized() {
4275        assert_eq!(
4276            CssPropertyWithConditionsVec::parse_at_rule("container sidebar"),
4277            Some(vec![DynamicSelector::ContainerName(
4278                AzString::from_const_str("sidebar")
4279            )])
4280        );
4281        let conds = CssPropertyWithConditionsVec::parse_at_rule("container (min-width: 400px)")
4282            .expect("sized container must parse");
4283        assert_eq!(conds.len(), 1);
4284        // Destructured rather than compared with `==`: `MinMaxRange`'s derived PartialEq
4285        // is not reflexive while its `max` is the NaN sentinel (see
4286        // `nan_sentinel_range_selector_is_reflexive_under_partial_eq`).
4287        match conds[0] {
4288            DynamicSelector::ContainerWidth(r) => {
4289                assert_eq!(r.min(), Some(400.0));
4290                assert_eq!(r.max(), None);
4291            }
4292            ref other => panic!("expected ContainerWidth, got {other:?}"),
4293        }
4294        let named = CssPropertyWithConditionsVec::parse_at_rule(
4295            "container sidebar (max-height: 200px)",
4296        )
4297        .expect("named + sized container must parse");
4298        assert_eq!(named.len(), 2);
4299        assert!(matches!(named[0], DynamicSelector::ContainerName(_)));
4300        assert!(matches!(named[1], DynamicSelector::ContainerHeight(_)));
4301    }
4302
4303    #[cfg(feature = "parser")]
4304    #[test]
4305    fn parse_at_rule_empty_and_garbage_is_none() {
4306        for s in [
4307            "",
4308            " ",
4309            "os",
4310            "os ",
4311            "os()",
4312            "os(notanos)",
4313            "media",
4314            "media ",
4315            "media (min-width: abc)",
4316            "theme",
4317            "container",
4318            "container ()",
4319            "nosuchrule",
4320            "\u{1F600}",
4321        ] {
4322            assert_eq!(
4323                CssPropertyWithConditionsVec::parse_at_rule(s),
4324                None,
4325                "{s:?} must not parse"
4326            );
4327        }
4328    }
4329
4330    #[cfg(feature = "parser")]
4331    #[test]
4332    fn parse_at_rule_huge_input_terminates() {
4333        assert_eq!(
4334            CssPropertyWithConditionsVec::parse_at_rule(&format!("os {}", "(".repeat(50_000))),
4335            None
4336        );
4337        assert_eq!(
4338            CssPropertyWithConditionsVec::parse_at_rule(&"z".repeat(500_000)),
4339            None
4340        );
4341        // A million-char language tag is accepted verbatim (no hang, no truncation).
4342        let long_lang = "e".repeat(100_000);
4343        assert_eq!(
4344            CssPropertyWithConditionsVec::parse_at_rule(&format!("lang {long_lang}")),
4345            Some(vec![DynamicSelector::Language(LanguageCondition::Prefix(
4346                AzString::from(long_lang)
4347            ))])
4348        );
4349    }
4350
4351    #[cfg(feature = "parser")]
4352    #[test]
4353    fn parse_media_query_media_types_and_dimensions() {
4354        assert_eq!(
4355            CssPropertyWithConditionsVec::parse_media_query("screen"),
4356            Some(vec![DynamicSelector::Media(MediaType::Screen)])
4357        );
4358        assert_eq!(
4359            CssPropertyWithConditionsVec::parse_media_query("print"),
4360            Some(vec![DynamicSelector::Media(MediaType::Print)])
4361        );
4362        assert_eq!(
4363            CssPropertyWithConditionsVec::parse_media_query("all"),
4364            Some(vec![DynamicSelector::Media(MediaType::All)])
4365        );
4366        let w = CssPropertyWithConditionsVec::parse_media_query("(min-width: 800px)")
4367            .expect("min-width must parse");
4368        assert_eq!(w.len(), 1);
4369        match w[0] {
4370            DynamicSelector::ViewportWidth(r) => {
4371                assert_eq!(r.min(), Some(800.0));
4372                assert_eq!(r.max(), None);
4373            }
4374            ref other => panic!("expected ViewportWidth, got {other:?}"),
4375        }
4376    }
4377
4378    #[cfg(feature = "parser")]
4379    #[test]
4380    fn parse_media_query_boundary_pixel_values() {
4381        // Zero and negative pixel values are accepted verbatim by `f32::parse`.
4382        let zero = CssPropertyWithConditionsVec::parse_media_query("(min-width: 0px)")
4383            .expect("0px must parse");
4384        match zero[0] {
4385            DynamicSelector::ViewportWidth(r) => assert_eq!(r.min(), Some(0.0)),
4386            ref other => panic!("expected ViewportWidth, got {other:?}"),
4387        }
4388        let neg = CssPropertyWithConditionsVec::parse_media_query("(max-height: -1px)")
4389            .expect("-1px must parse");
4390        match neg[0] {
4391            DynamicSelector::ViewportHeight(r) => assert_eq!(r.max(), Some(-1.0)),
4392            ref other => panic!("expected ViewportHeight, got {other:?}"),
4393        }
4394        // Missing / wrong unit is rejected (falls through to the media-type match).
4395        assert_eq!(
4396            CssPropertyWithConditionsVec::parse_media_query("(min-width: 800)"),
4397            None
4398        );
4399        assert_eq!(
4400            CssPropertyWithConditionsVec::parse_media_query("(min-width: 800em)"),
4401            None
4402        );
4403    }
4404
4405    #[cfg(feature = "parser")]
4406    #[test]
4407    fn parse_media_query_empty_and_garbage_is_none() {
4408        for s in [
4409            "",
4410            " ",
4411            "(",
4412            ")",
4413            "()",
4414            "(:)",
4415            "(min-width)",
4416            "(min-width: )",
4417            "(nosuchfeature: 1px)",
4418            "SCREEN",
4419            "\u{1F600}",
4420            "(🦀: 1px)",
4421        ] {
4422            assert_eq!(
4423                CssPropertyWithConditionsVec::parse_media_query(s),
4424                None,
4425                "{s:?} must not parse"
4426            );
4427        }
4428        assert_eq!(
4429            CssPropertyWithConditionsVec::parse_media_query(&"(".repeat(100_000)),
4430            None
4431        );
4432    }
4433
4434    // RED (genuine bug, low severity): `value.parse::<f32>()` accepts "NaN"/"nan", and
4435    // `MinMaxRange` uses NaN as the "no limit" sentinel. So `(min-width: NaNpx)` — an
4436    // invalid media feature — silently becomes an *unconditional* viewport-width match
4437    // instead of being rejected. Per CSS, an unparseable feature value makes the query
4438    // invalid (never matches); it must certainly not make it always match.
4439    #[cfg(feature = "parser")]
4440    #[test]
4441    fn parse_media_query_nan_pixel_value_is_rejected() {
4442        assert_eq!(
4443            CssPropertyWithConditionsVec::parse_media_query("(min-width: NaNpx)"),
4444            None,
4445            "a NaN px value collapses into the 'no limit' sentinel and matches everything"
4446        );
4447    }
4448
4449    #[cfg(feature = "parser")]
4450    #[test]
4451    fn parse_media_query_infinite_pixel_value_never_matches() {
4452        // "inf" also parses as f32 — unlike NaN it degrades safely (matches nothing),
4453        // so assert that rather than a rejection.
4454        let q = CssPropertyWithConditionsVec::parse_media_query("(min-width: infpx)")
4455            .expect("infpx currently parses");
4456        let ctx = DynamicSelectorContext::default().with_viewport(1e30, 1000.0);
4457        assert!(!q[0].matches(&ctx), "an infinite min-width can never be met");
4458    }
4459
4460    #[cfg(feature = "parser")]
4461    #[test]
4462    fn parse_media_feature_inline_known_features() {
4463        assert_eq!(
4464            CssPropertyWithConditionsVec::parse_media_feature_inline("orientation", "PORTRAIT"),
4465            Some(DynamicSelector::Orientation(OrientationType::Portrait))
4466        );
4467        assert_eq!(
4468            CssPropertyWithConditionsVec::parse_media_feature_inline(
4469                "prefers-color-scheme",
4470                "Dark"
4471            ),
4472            Some(DynamicSelector::Theme(ThemeCondition::Dark))
4473        );
4474        assert_eq!(
4475            CssPropertyWithConditionsVec::parse_media_feature_inline(
4476                "prefers-reduced-motion",
4477                "reduce"
4478            ),
4479            Some(DynamicSelector::PrefersReducedMotion(BoolCondition::True))
4480        );
4481        assert_eq!(
4482            CssPropertyWithConditionsVec::parse_media_feature_inline(
4483                "prefers-reduced-motion",
4484                "no-preference"
4485            ),
4486            Some(DynamicSelector::PrefersReducedMotion(BoolCondition::False))
4487        );
4488        assert_eq!(
4489            CssPropertyWithConditionsVec::parse_media_feature_inline("prefers-contrast", "more"),
4490            Some(DynamicSelector::PrefersHighContrast(BoolCondition::True))
4491        );
4492        assert_eq!(
4493            CssPropertyWithConditionsVec::parse_media_feature_inline(
4494                "prefers-high-contrast",
4495                "none"
4496            ),
4497            Some(DynamicSelector::PrefersHighContrast(BoolCondition::False))
4498        );
4499    }
4500
4501    #[cfg(feature = "parser")]
4502    #[test]
4503    fn parse_media_feature_inline_rejects_unknown_keys_and_values() {
4504        assert_eq!(
4505            CssPropertyWithConditionsVec::parse_media_feature_inline("orientation", ""),
4506            None
4507        );
4508        assert_eq!(
4509            CssPropertyWithConditionsVec::parse_media_feature_inline("orientation", "sideways"),
4510            None
4511        );
4512        assert_eq!(
4513            CssPropertyWithConditionsVec::parse_media_feature_inline("", ""),
4514            None
4515        );
4516        assert_eq!(
4517            CssPropertyWithConditionsVec::parse_media_feature_inline("nosuchkey", "dark"),
4518            None
4519        );
4520        assert_eq!(
4521            CssPropertyWithConditionsVec::parse_media_feature_inline(
4522                "prefers-color-scheme",
4523                "\u{1F600}"
4524            ),
4525            None
4526        );
4527        // The key is *not* trimmed by this helper.
4528        assert_eq!(
4529            CssPropertyWithConditionsVec::parse_media_feature_inline(" orientation", "portrait"),
4530            None
4531        );
4532        assert_eq!(
4533            CssPropertyWithConditionsVec::parse_media_feature_inline(
4534                &"k".repeat(200_000),
4535                &"v".repeat(200_000)
4536            ),
4537            None
4538        );
4539    }
4540
4541    #[cfg(feature = "parser")]
4542    #[test]
4543    fn parse_property_segment_valid_and_invalid() {
4544        let key_map = crate::props::property::CssKeyMap::get();
4545        let ok = CssPropertyWithConditionsVec::parse_property_segment("color: red", &[], &key_map)
4546            .expect("color: red must parse");
4547        assert_eq!(ok.len(), 1);
4548        assert!(!ok[0].is_conditional());
4549
4550        // A shorthand expands into several properties, all sharing the conditions.
4551        let inherited = vec![DynamicSelector::PseudoState(PseudoStateType::Hover)];
4552        let shorthand = CssPropertyWithConditionsVec::parse_property_segment(
4553            "padding: 10px",
4554            &inherited,
4555            &key_map,
4556        )
4557        .expect("padding shorthand must parse");
4558        assert!(shorthand.len() > 1, "padding must expand to >1 property");
4559        for p in &shorthand {
4560            assert_eq!(p.apply_if.as_slice(), inherited.as_slice());
4561        }
4562
4563        for s in ["", "   ", "color", "color:", ": red", "nosuchprop: red", "\u{1F600}"] {
4564            assert!(
4565                CssPropertyWithConditionsVec::parse_property_segment(s, &[], &key_map).is_none(),
4566                "{s:?} must not parse"
4567            );
4568        }
4569    }
4570
4571    #[cfg(feature = "parser")]
4572    #[test]
4573    fn parse_block_segment_requires_balanced_braces() {
4574        let key_map = crate::props::property::CssKeyMap::get();
4575        // No brace at all.
4576        assert!(CssPropertyWithConditionsVec::parse_block_segment(
4577            "color: red",
4578            &[],
4579            &key_map
4580        )
4581        .is_none());
4582        // Opening brace, no closing brace.
4583        assert!(CssPropertyWithConditionsVec::parse_block_segment(
4584            ":hover { color: red",
4585            &[],
4586            &key_map
4587        )
4588        .is_none());
4589        // `}` before `{` -> content_end <= content_start -> None.
4590        assert!(
4591            CssPropertyWithConditionsVec::parse_block_segment("}{", &[], &key_map).is_none()
4592        );
4593        // Empty body is an empty (but valid) block.
4594        let empty = CssPropertyWithConditionsVec::parse_block_segment(
4595            ":hover {}",
4596            &[],
4597            &key_map,
4598        );
4599        // "{}" has content_end == content_start -> rejected by the guard.
4600        assert!(empty.is_none());
4601        // Valid block.
4602        let ok = CssPropertyWithConditionsVec::parse_block_segment(
4603            ":hover { color: red; }",
4604            &[],
4605            &key_map,
4606        )
4607        .expect("valid block");
4608        assert_eq!(ok.len(), 1);
4609        assert!(ok[0].is_pseudo_state_only());
4610    }
4611
4612    #[cfg(feature = "parser")]
4613    #[test]
4614    fn parse_with_conditions_prepends_inherited_conditions() {
4615        let inherited = vec![DynamicSelector::Os(OsCondition::Linux)];
4616        let props =
4617            CssPropertyWithConditionsVec::parse_with_conditions("color: red;", &inherited)
4618                .into_library_owned_vec();
4619        assert_eq!(props.len(), 1);
4620        assert_eq!(props[0].apply_if.as_slice(), inherited.as_slice());
4621
4622        // Empty input with inherited conditions still yields nothing.
4623        let none = CssPropertyWithConditionsVec::parse_with_conditions("   ", &inherited)
4624            .into_library_owned_vec();
4625        assert!(none.is_empty());
4626    }
4627
4628    #[cfg(feature = "parser")]
4629    #[test]
4630    fn parsed_media_selector_evaluates_against_a_context() {
4631        // End-to-end: parse -> match. Guards against a parse that silently produces an
4632        // always-true or never-true condition.
4633        let props =
4634            CssPropertyWithConditionsVec::parse("@media (min-width: 800px) { color: red; }")
4635                .into_library_owned_vec();
4636        assert_eq!(props.len(), 1);
4637        let base = DynamicSelectorContext::default();
4638        assert!(props[0].matches(&base.with_viewport(1024.0, 768.0)));
4639        assert!(props[0].matches(&base.with_viewport(800.0, 600.0)));
4640        assert!(!props[0].matches(&base.with_viewport(799.0, 600.0)));
4641    }
4642}