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