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