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