Skip to main content

stygian_browser/coherence/
report.rs

1//! Cross-context coherence drift report schema.
2//!
3//! Defines the per-context identity surfaces, the `Skipped` marker for
4//! unavailable contexts, and the drift-diagnostic records emitted by
5//! [`crate::coherence::probes::CoherenceProbe`]. All comparison logic
6//! in this file is **pure Rust with no I/O** so it can be unit-tested
7//! deterministically without booting Chrome.
8//!
9//! ## Feature flag
10//!
11//! The coherence module is **default-on** and is always compiled as
12//! part of the `stygian-browser` crate.
13//!
14//! ## Separation of hard failures vs known limitations
15//!
16//! Drift is classified into two severity bands:
17//!
18//! - [`DriftSeverity::Hard`] — user-agent, platform, languages, and
19//!   `navigator.webdriver` MUST be identical across all contexts.
20//!   Drift here is a strong anti-bot detection signal.
21//! - [`DriftSeverity::KnownLimitation`] — fields like
22//!   `hardwareConcurrency` and `deviceMemory` are documented to
23//!   differ between Document and Worker contexts in some browsers.
24//!   Drift here is a known limitation, not a stealth regression.
25//!
26//! Reports always carry both bands; callers can filter on
27//! [`DriftSeverity`] to surface regressions only.
28
29use std::collections::BTreeSet;
30
31use serde::{Deserialize, Serialize};
32
33use crate::freshness::FreshnessReport;
34
35// ─── Context kind ──────────────────────────────────────────────────────────────
36
37/// Logical browser context in which an identity surface was observed.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum ContextKind {
41    /// Top-level document (`window`).
42    Top,
43    /// Same-origin iframe (`iframe.contentWindow`).
44    Iframe,
45    /// Dedicated or shared worker (`WorkerGlobalScope`).
46    Worker,
47}
48
49impl ContextKind {
50    /// Stable snake-case label used in telemetry.
51    #[must_use]
52    pub const fn label(self) -> &'static str {
53        match self {
54            Self::Top => "top",
55            Self::Iframe => "iframe",
56            Self::Worker => "worker",
57        }
58    }
59}
60
61// ─── Identity surface ──────────────────────────────────────────────────────────
62
63/// Identity surface probed in a single browser context.
64///
65/// All fields are optional so partial probe results (e.g. a context
66/// where `navigator.deviceMemory` is undefined) round-trip cleanly.
67///
68/// # Example
69///
70/// ```
71/// use stygian_browser::coherence::IdentitySurface;
72///
73/// let s = IdentitySurface {
74///     user_agent: Some("Mozilla/5.0 ...".to_string()),
75///     platform: Some("MacIntel".to_string()),
76///     languages: Some("en-US,en".to_string()),
77///     hardware_concurrency: Some(8),
78///     device_memory: None,
79///     timezone: Some("America/Los_Angeles".to_string()),
80///     screen_width: Some(1920),
81///     screen_height: Some(1080),
82///     color_depth: Some(24),
83///     webdriver: Some(false),
84/// };
85/// assert_eq!(s.user_agent.as_deref(), Some("Mozilla/5.0 ..."));
86/// ```
87#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
88pub struct IdentitySurface {
89    /// `navigator.userAgent`.
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub user_agent: Option<String>,
92    /// `navigator.platform`.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub platform: Option<String>,
95    /// `navigator.languages` joined by `","` for stable comparisons.
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub languages: Option<String>,
98    /// `navigator.hardwareConcurrency` (allowed to drift for workers).
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub hardware_concurrency: Option<u32>,
101    /// `navigator.deviceMemory` (often undefined in workers).
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub device_memory: Option<u32>,
104    /// `Intl.DateTimeFormat().resolvedOptions().timeZone`.
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub timezone: Option<String>,
107    /// `screen.width` (Document + same-origin iframe only).
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub screen_width: Option<u32>,
110    /// `screen.height` (Document + same-origin iframe only).
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub screen_height: Option<u32>,
113    /// `screen.colorDepth` (Document + same-origin iframe only).
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub color_depth: Option<u32>,
116    /// `navigator.webdriver` (`Some(false)` on a clean browser).
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub webdriver: Option<bool>,
119}
120
121impl IdentitySurface {
122    /// `true` when **no** field is populated — useful for asserting a
123    /// context was probed but produced no observations.
124    #[must_use]
125    pub const fn is_empty(&self) -> bool {
126        self.user_agent.is_none()
127            && self.platform.is_none()
128            && self.languages.is_none()
129            && self.hardware_concurrency.is_none()
130            && self.device_memory.is_none()
131            && self.timezone.is_none()
132            && self.screen_width.is_none()
133            && self.screen_height.is_none()
134            && self.color_depth.is_none()
135            && self.webdriver.is_none()
136    }
137
138    /// Build a deterministic signature for [`crate::freshness::signature_hash`].
139    ///
140    /// Concatenates the fields most likely to be anti-bot detection
141    /// targets: `user_agent`, `platform`, `languages`, `timezone`,
142    /// `screen_width`, `screen_height`, `color_depth`. Missing
143    /// fields emit `"-"` so signatures are stable across partial
144    /// observations.
145    #[must_use]
146    pub fn signature_parts(&self) -> Vec<String> {
147        vec![
148            self.user_agent.clone().unwrap_or_else(|| "-".to_string()),
149            self.platform.clone().unwrap_or_else(|| "-".to_string()),
150            self.languages.clone().unwrap_or_else(|| "-".to_string()),
151            self.timezone.clone().unwrap_or_else(|| "-".to_string()),
152            self.screen_width
153                .map_or_else(|| "-".to_string(), |v| v.to_string()),
154            self.screen_height
155                .map_or_else(|| "-".to_string(), |v| v.to_string()),
156            self.color_depth
157                .map_or_else(|| "-".to_string(), |v| v.to_string()),
158        ]
159    }
160}
161
162// ─── Context observation ───────────────────────────────────────────────────────
163
164/// Result of probing a single context: either an observed
165/// [`IdentitySurface`] or a `Skipped` marker describing why the
166/// probe could not run.
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(tag = "status", rename_all = "snake_case")]
169pub enum ContextObservation {
170    /// The context was probed and produced a (possibly partial)
171    /// identity surface.
172    Observed {
173        /// Captured identity surface.
174        surface: IdentitySurface,
175    },
176    /// The context could not be probed (worker unsupported, iframe
177    /// creation blocked, etc.) — no panic, just a structured marker.
178    Skipped {
179        /// Human-readable reason for the skip.
180        reason: String,
181    },
182}
183
184impl ContextObservation {
185    /// Convenience constructor for an [`Observed`][Self::Observed] surface.
186    #[must_use]
187    pub const fn observed(surface: IdentitySurface) -> Self {
188        Self::Observed { surface }
189    }
190
191    /// Convenience constructor for a [`Skipped`][Self::Skipped] marker.
192    #[must_use]
193    pub fn skipped(reason: impl Into<String>) -> Self {
194        Self::Skipped {
195            reason: reason.into(),
196        }
197    }
198
199    /// `true` when the observation is an [`Observed`][Self::Observed]
200    /// variant (regardless of how many fields were populated).
201    #[must_use]
202    pub const fn is_observed(&self) -> bool {
203        matches!(self, Self::Observed { .. })
204    }
205
206    /// `true` when the observation is a [`Skipped`][Self::Skipped]
207    /// marker.
208    #[must_use]
209    pub const fn is_skipped(&self) -> bool {
210        matches!(self, Self::Skipped { .. })
211    }
212
213    /// Borrow the captured surface, when observed.
214    #[must_use]
215    pub const fn surface(&self) -> Option<&IdentitySurface> {
216        match self {
217            Self::Observed { surface } => Some(surface),
218            Self::Skipped { .. } => None,
219        }
220    }
221}
222
223// ─── Drift diagnostic ─────────────────────────────────────────────────────────
224
225/// Drift severity classification.
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
227#[serde(rename_all = "snake_case")]
228pub enum DriftSeverity {
229    /// Hard stealth regression — UA, platform, languages, webdriver
230    /// should be identical across contexts.
231    Hard,
232    /// Known limitation — the field is documented to differ between
233    /// Document and Worker contexts in some browser engines.
234    KnownLimitation,
235}
236
237impl DriftSeverity {
238    /// Stable snake-case label.
239    #[must_use]
240    pub const fn label(self) -> &'static str {
241        match self {
242            Self::Hard => "hard",
243            Self::KnownLimitation => "known_limitation",
244        }
245    }
246}
247
248/// Single drift record describing one field that disagreed across
249/// two contexts.
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
251pub struct DriftDiagnostic {
252    /// First context in the comparison.
253    pub context_a: ContextKind,
254    /// Second context in the comparison.
255    pub context_b: ContextKind,
256    /// Field name (`snake_case`, e.g. `"user_agent"`).
257    pub field: String,
258    /// Observed value in `context_a` (rendered via `Display`).
259    pub observed_a: String,
260    /// Observed value in `context_b` (rendered via `Display`).
261    pub observed_b: String,
262    /// Severity classification.
263    pub severity: DriftSeverity,
264}
265
266impl DriftDiagnostic {
267    /// Stable, machine-readable reason tag (`"top:iframe:user_agent:hard"`).
268    #[must_use]
269    pub fn reason_tag(&self) -> String {
270        format!(
271            "{}:{}:{}:{}",
272            self.context_a.label(),
273            self.context_b.label(),
274            self.field,
275            self.severity.label()
276        )
277    }
278}
279
280// ─── CoherenceDriftReport ──────────────────────────────────────────────────────
281
282/// Aggregate coherence drift report covering top-level, iframe, and
283/// (best-effort) worker contexts.
284///
285/// Skipped contexts never panic — they are emitted as
286/// [`ContextObservation::Skipped`] markers so callers can attribute
287/// missing coverage to the runtime, not a probe failure.
288///
289/// # Example
290///
291/// ```
292/// use stygian_browser::coherence::{
293///     CoherenceDriftReport, ContextObservation, ContextKind, IdentitySurface,
294/// };
295///
296/// let s = IdentitySurface {
297///     user_agent: Some("Mozilla/5.0 ...".to_string()),
298///     platform: Some("MacIntel".to_string()),
299///     languages: Some("en-US".to_string()),
300///     ..IdentitySurface::default()
301/// };
302/// let report = CoherenceDriftReport {
303///     top: ContextObservation::observed(s.clone()),
304///     iframe: ContextObservation::observed(s.clone()),
305///     worker: ContextObservation::skipped("Worker unsupported"),
306///     drifts: Vec::new(),
307///     freshness: None,
308/// };
309/// assert!(report.is_coherent());
310/// assert!(!report.has_hard_drift());
311/// assert_eq!(report.observed_context_count(), 2);
312/// ```
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314pub struct CoherenceDriftReport {
315    /// Top-level document observation.
316    pub top: ContextObservation,
317    /// Same-origin iframe observation.
318    pub iframe: ContextObservation,
319    /// Dedicated/shared worker observation (best-effort, may be `Skipped`).
320    pub worker: ContextObservation,
321    /// Drift diagnostics comparing all available context pairs.
322    pub drifts: Vec<DriftDiagnostic>,
323    /// Optional freshness report attached when the probe was
324    /// supplied with a [`crate::freshness::FreshnessContract`].
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub freshness: Option<FreshnessReport>,
327}
328
329impl CoherenceDriftReport {
330    /// `true` when no drift diagnostics were emitted (i.e. all
331    /// observed contexts are coherent). Skipped contexts are not
332    /// counted as drift.
333    #[must_use]
334    pub const fn is_coherent(&self) -> bool {
335        self.drifts.is_empty()
336    }
337
338    /// `true` when at least one [`DriftSeverity::Hard`] drift is
339    /// present. Callers should treat this as a stealth regression.
340    #[must_use]
341    pub fn has_hard_drift(&self) -> bool {
342        self.drifts
343            .iter()
344            .any(|d| d.severity == DriftSeverity::Hard)
345    }
346
347    /// Number of contexts that were actually observed (not skipped).
348    #[must_use]
349    pub fn observed_context_count(&self) -> usize {
350        [&self.top, &self.iframe, &self.worker]
351            .iter()
352            .filter(|o| o.is_observed())
353            .count()
354    }
355
356    /// Number of contexts skipped.
357    #[must_use]
358    pub fn skipped_context_count(&self) -> usize {
359        [&self.top, &self.iframe, &self.worker]
360            .iter()
361            .filter(|o| o.is_skipped())
362            .count()
363    }
364
365    /// Iterate over all [`DriftSeverity::Hard`] diagnostics.
366    pub fn hard_drifts(&self) -> impl Iterator<Item = &DriftDiagnostic> {
367        self.drifts
368            .iter()
369            .filter(|d| d.severity == DriftSeverity::Hard)
370    }
371
372    /// Iterate over all [`DriftSeverity::KnownLimitation`] diagnostics.
373    pub fn known_limitations(&self) -> impl Iterator<Item = &DriftDiagnostic> {
374        self.drifts
375            .iter()
376            .filter(|d| d.severity == DriftSeverity::KnownLimitation)
377    }
378}
379
380// ─── Comparison helpers (pure Rust, no I/O) ────────────────────────────────────
381
382/// Pair of contexts to compare.
383#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
384pub enum ContextPair {
385    /// Top vs Iframe.
386    TopIframe,
387    /// Top vs Worker.
388    TopWorker,
389    /// Iframe vs Worker.
390    IframeWorker,
391}
392
393impl ContextPair {
394    /// All three pairs in deterministic order.
395    pub const ALL: [Self; 3] = [Self::TopIframe, Self::TopWorker, Self::IframeWorker];
396
397    /// Resolve the [`ContextKind`] for side `a` / `b`.
398    #[must_use]
399    pub const fn sides(self) -> (ContextKind, ContextKind) {
400        match self {
401            Self::TopIframe => (ContextKind::Top, ContextKind::Iframe),
402            Self::TopWorker => (ContextKind::Top, ContextKind::Worker),
403            Self::IframeWorker => (ContextKind::Iframe, ContextKind::Worker),
404        }
405    }
406}
407
408/// Fields that MUST be identical across all contexts.
409const HARD_FIELDS: &[&str] = &["user_agent", "platform", "languages", "webdriver"];
410
411/// Fields where drift is a known browser-engine limitation, not a
412/// stealth regression.
413#[allow(dead_code)]
414const KNOWN_LIMITATION_FIELDS: &[&str] = &[
415    "hardware_concurrency",
416    "device_memory",
417    "screen_width",
418    "screen_height",
419    "color_depth",
420    "timezone",
421];
422
423/// Severity for a given field name.
424#[must_use]
425pub fn field_severity(field: &str) -> DriftSeverity {
426    if HARD_FIELDS.contains(&field) {
427        DriftSeverity::Hard
428    } else {
429        DriftSeverity::KnownLimitation
430    }
431}
432
433/// Compare `a` and `b` (both `Observed`) and emit a
434/// [`Vec<DriftDiagnostic>`] covering every disagreed field. Returns
435/// an empty vec when `a == b`.
436///
437/// `pair` is recorded into each diagnostic so the report can
438/// attribute drift to the right context pair.
439#[must_use]
440pub fn diff_surfaces(
441    pair: ContextPair,
442    a: &IdentitySurface,
443    b: &IdentitySurface,
444) -> Vec<DriftDiagnostic> {
445    let (kind_a, kind_b) = pair.sides();
446    let mut drifts = Vec::new();
447
448    let pairs: [(&str, Option<String>, Option<String>); 10] = [
449        ("user_agent", a.user_agent.clone(), b.user_agent.clone()),
450        ("platform", a.platform.clone(), b.platform.clone()),
451        ("languages", a.languages.clone(), b.languages.clone()),
452        (
453            "hardware_concurrency",
454            a.hardware_concurrency.map(|v| v.to_string()),
455            b.hardware_concurrency.map(|v| v.to_string()),
456        ),
457        (
458            "device_memory",
459            a.device_memory.map(|v| v.to_string()),
460            b.device_memory.map(|v| v.to_string()),
461        ),
462        ("timezone", a.timezone.clone(), b.timezone.clone()),
463        (
464            "screen_width",
465            a.screen_width.map(|v| v.to_string()),
466            b.screen_width.map(|v| v.to_string()),
467        ),
468        (
469            "screen_height",
470            a.screen_height.map(|v| v.to_string()),
471            b.screen_height.map(|v| v.to_string()),
472        ),
473        (
474            "color_depth",
475            a.color_depth.map(|v| v.to_string()),
476            b.color_depth.map(|v| v.to_string()),
477        ),
478        (
479            "webdriver",
480            a.webdriver.map(|v| v.to_string()),
481            b.webdriver.map(|v| v.to_string()),
482        ),
483    ];
484
485    for (field, va, vb) in pairs {
486        if va == vb {
487            continue;
488        }
489        // Treat (Some, None) and (None, Some) as drift too — a
490        // context that cannot see a field is a meaningful signal
491        // when the other context can.
492        let observed_a = va.unwrap_or_else(|| "<absent>".to_string());
493        let observed_b = vb.unwrap_or_else(|| "<absent>".to_string());
494        drifts.push(DriftDiagnostic {
495            context_a: kind_a,
496            context_b: kind_b,
497            field: field.to_string(),
498            observed_a,
499            observed_b,
500            severity: field_severity(field),
501        });
502    }
503
504    drifts
505}
506
507/// Build a [`CoherenceDriftReport`] from three [`ContextObservation`]s
508/// by running [`diff_surfaces`] for every applicable pair.
509///
510/// Skipped contexts are excluded from the comparison without
511/// raising an error or panic.
512#[must_use]
513pub fn build_report(
514    top: ContextObservation,
515    iframe: ContextObservation,
516    worker: ContextObservation,
517    freshness: Option<FreshnessReport>,
518) -> CoherenceDriftReport {
519    let mut drifts = Vec::new();
520    let observed = [
521        (ContextKind::Top, &top),
522        (ContextKind::Iframe, &iframe),
523        (ContextKind::Worker, &worker),
524    ];
525
526    for pair in ContextPair::ALL {
527        let (ka, kb) = pair.sides();
528        let surface_a = observed
529            .iter()
530            .find(|(k, _)| *k == ka)
531            .and_then(|(_, o)| o.surface());
532        let surface_b = observed
533            .iter()
534            .find(|(k, _)| *k == kb)
535            .and_then(|(_, o)| o.surface());
536        if let (Some(sa), Some(sb)) = (surface_a, surface_b) {
537            drifts.extend(diff_surfaces(pair, sa, sb));
538        }
539    }
540
541    CoherenceDriftReport {
542        top,
543        iframe,
544        worker,
545        drifts,
546        freshness,
547    }
548}
549
550// ─── Signature helper ─────────────────────────────────────────────────────────
551
552/// Compute a deterministic signature hash from an [`IdentitySurface`].
553///
554/// Wraps [`crate::freshness::signature_hash`] so the same input always
555/// produces the same `"fnv64:<hex>"` string. Useful for cross-context
556/// signatures that can then be compared against a
557/// [`crate::freshness::FreshnessContract`].
558///
559/// # Example
560///
561/// ```
562/// use stygian_browser::coherence::{IdentitySurface, surface_signature};
563///
564/// let s = IdentitySurface {
565///     user_agent: Some("Mozilla/5.0 ...".to_string()),
566///     platform: Some("MacIntel".to_string()),
567///     ..IdentitySurface::default()
568/// };
569/// let h1 = surface_signature(&s);
570/// let h2 = surface_signature(&s);
571/// assert_eq!(h1, h2);
572/// assert!(h1.starts_with("fnv64:"));
573/// ```
574#[must_use]
575pub fn surface_signature(surface: &IdentitySurface) -> String {
576    let parts = surface.signature_parts();
577    let borrowed: Vec<&str> = parts.iter().map(String::as_str).collect();
578    crate::freshness::signature_hash(&borrowed)
579}
580
581/// Fields that contribute to [`surface_signature`]. Exposed for
582/// callers that need to extend the contract.
583#[must_use]
584pub fn signature_field_names() -> &'static BTreeSet<&'static str> {
585    // Lazily-initialised static keeps the helper callable from
586    // `const` contexts in callers without a const-friendly BTreeMap
587    // literal in the source.
588    static NAMES: std::sync::OnceLock<BTreeSet<&'static str>> = std::sync::OnceLock::new();
589    NAMES.get_or_init(|| {
590        [
591            "user_agent",
592            "platform",
593            "languages",
594            "timezone",
595            "screen_width",
596            "screen_height",
597            "color_depth",
598        ]
599        .into_iter()
600        .collect()
601    })
602}
603
604// ─── Tests ────────────────────────────────────────────────────────────────────
605
606#[cfg(test)]
607#[allow(
608    clippy::unwrap_used,
609    clippy::expect_used,
610    clippy::panic,
611    clippy::indexing_slicing
612)]
613mod tests {
614    use super::*;
615    use crate::freshness::{
616        DomainClass, FreshnessCheckInput, FreshnessContract, FreshnessPolicyKind,
617    };
618    use std::time::Duration;
619
620    fn surface_a() -> IdentitySurface {
621        IdentitySurface {
622            user_agent: Some("Mozilla/5.0".to_string()),
623            platform: Some("MacIntel".to_string()),
624            languages: Some("en-US,en".to_string()),
625            hardware_concurrency: Some(8),
626            device_memory: Some(8),
627            timezone: Some("America/Los_Angeles".to_string()),
628            screen_width: Some(1920),
629            screen_height: Some(1080),
630            color_depth: Some(24),
631            webdriver: Some(false),
632        }
633    }
634
635    fn surface_b_drift_ua_platform() -> IdentitySurface {
636        IdentitySurface {
637            user_agent: Some("Mozilla/4.0".to_string()),
638            platform: Some("Win32".to_string()),
639            languages: Some("en-US,en".to_string()),
640            hardware_concurrency: Some(8),
641            device_memory: Some(8),
642            timezone: Some("America/Los_Angeles".to_string()),
643            screen_width: Some(1920),
644            screen_height: Some(1080),
645            color_depth: Some(24),
646            webdriver: Some(false),
647        }
648    }
649
650    fn surface_worker_differs_in_hardware() -> IdentitySurface {
651        IdentitySurface {
652            user_agent: Some("Mozilla/5.0".to_string()),
653            platform: Some("MacIntel".to_string()),
654            languages: Some("en-US,en".to_string()),
655            hardware_concurrency: Some(2), // documented worker limitation
656            device_memory: None,           // workers lack deviceMemory
657            timezone: Some("America/Los_Angeles".to_string()),
658            screen_width: None, // workers have no screen
659            screen_height: None,
660            color_depth: None,
661            webdriver: Some(false),
662        }
663    }
664
665    #[test]
666    fn diff_surfaces_empty_when_identical() {
667        let a = surface_a();
668        let b = a.clone();
669        let drifts = diff_surfaces(ContextPair::TopIframe, &a, &b);
670        assert!(drifts.is_empty());
671    }
672
673    #[test]
674    fn diff_surfaces_emits_hard_drift_for_ua_and_platform() {
675        let a = surface_a();
676        let b = surface_b_drift_ua_platform();
677        let drifts = diff_surfaces(ContextPair::TopIframe, &a, &b);
678        let fields: Vec<&str> = drifts.iter().map(|d| d.field.as_str()).collect();
679        assert!(fields.contains(&"user_agent"));
680        assert!(fields.contains(&"platform"));
681        assert!(!fields.contains(&"languages"));
682        // All emitted drifts are hard
683        for d in &drifts {
684            assert_eq!(d.severity, DriftSeverity::Hard);
685            assert_eq!(d.context_a, ContextKind::Top);
686            assert_eq!(d.context_b, ContextKind::Iframe);
687        }
688    }
689
690    #[test]
691    fn diff_surfaces_classifies_worker_hardware_drift_as_known_limitation() {
692        let a = surface_a();
693        let b = surface_worker_differs_in_hardware();
694        let drifts = diff_surfaces(ContextPair::TopWorker, &a, &b);
695        let hardware: Vec<&DriftDiagnostic> = drifts
696            .iter()
697            .filter(|d| d.field == "hardware_concurrency")
698            .collect();
699        assert_eq!(hardware.len(), 1);
700        assert_eq!(hardware[0].severity, DriftSeverity::KnownLimitation);
701
702        let device_memory: Vec<&DriftDiagnostic> = drifts
703            .iter()
704            .filter(|d| d.field == "device_memory")
705            .collect();
706        assert_eq!(device_memory.len(), 1);
707        assert_eq!(device_memory[0].severity, DriftSeverity::KnownLimitation);
708        // "<absent>" rendered for the missing field
709        assert_eq!(device_memory[0].observed_b, "<absent>");
710    }
711
712    #[test]
713    fn build_report_skips_unavailable_contexts_without_panic() {
714        let top = ContextObservation::observed(surface_a());
715        let iframe = ContextObservation::skipped("iframe blocked by CSP");
716        let worker = ContextObservation::skipped("Worker unsupported");
717        let report = build_report(top, iframe, worker, None);
718        // No top↔iframe, top↔worker, iframe↔worker comparisons possible
719        assert!(report.drifts.is_empty());
720        assert_eq!(report.observed_context_count(), 1);
721        assert_eq!(report.skipped_context_count(), 2);
722        assert!(report.is_coherent());
723        assert!(!report.has_hard_drift());
724    }
725
726    #[test]
727    fn build_report_flags_hard_drift_between_top_and_iframe() {
728        let top = ContextObservation::observed(surface_a());
729        let iframe = ContextObservation::observed(surface_b_drift_ua_platform());
730        let worker = ContextObservation::skipped("Worker unsupported");
731        let report = build_report(top, iframe, worker, None);
732        assert!(!report.is_coherent());
733        assert!(report.has_hard_drift());
734        let hard_count = report.hard_drifts().count();
735        assert!(hard_count >= 2); // UA + platform
736    }
737
738    #[test]
739    fn build_report_flags_only_known_limitations_for_worker_drift() {
740        let top = ContextObservation::observed(surface_a());
741        let iframe = ContextObservation::observed(surface_a());
742        let worker = ContextObservation::observed(surface_worker_differs_in_hardware());
743        let report = build_report(top, iframe, worker, None);
744        // Top↔iframe must be clean
745        let top_iframe_drift_exists = report
746            .drifts
747            .iter()
748            .any(|d| d.context_a == ContextKind::Top && d.context_b == ContextKind::Iframe);
749        assert!(!top_iframe_drift_exists);
750        // Top↔worker + Iframe↔worker must contain only known-limitation
751        // diagnostics
752        for d in &report.drifts {
753            assert_eq!(d.severity, DriftSeverity::KnownLimitation);
754        }
755        assert!(!report.has_hard_drift());
756        assert!(report.known_limitations().count() > 0);
757    }
758
759    #[test]
760    fn surface_signature_is_deterministic_and_starts_with_fnv64() {
761        let a = surface_a();
762        let h1 = surface_signature(&a);
763        let h2 = surface_signature(&a);
764        assert_eq!(h1, h2);
765        assert!(h1.starts_with("fnv64:"));
766    }
767
768    #[test]
769    fn surface_signature_changes_with_user_agent() {
770        let a = surface_a();
771        let mut b = a.clone();
772        b.user_agent = Some("Mozilla/4.0".to_string());
773        assert_ne!(surface_signature(&a), surface_signature(&b));
774    }
775
776    #[test]
777    fn report_carries_freshness_when_supplied() {
778        let contract = FreshnessContract::with_signature(
779            "example.com",
780            surface_signature(&surface_a()).as_str(),
781            1_700_000_000_000,
782            Duration::from_mins(1),
783            FreshnessPolicyKind::Standard,
784        )
785        .expect("contract");
786        let input = FreshnessCheckInput::new(
787            "example.com",
788            Some(surface_signature(&surface_a()).as_str()),
789            1_700_000_030_000,
790        );
791        let report = build_report(
792            ContextObservation::observed(surface_a()),
793            ContextObservation::observed(surface_a()),
794            ContextObservation::skipped("Worker unsupported"),
795            Some(FreshnessReport::evaluate(&contract, &input)),
796        );
797        let fr = report
798            .freshness
799            .as_ref()
800            .expect("freshness report attached");
801        assert!(fr.decision.is_valid());
802        assert_eq!(fr.domain_class, DomainClass::Default);
803    }
804
805    #[test]
806    fn drift_reason_tag_is_stable() {
807        let d = DriftDiagnostic {
808            context_a: ContextKind::Top,
809            context_b: ContextKind::Worker,
810            field: "user_agent".to_string(),
811            observed_a: "a".to_string(),
812            observed_b: "b".to_string(),
813            severity: DriftSeverity::Hard,
814        };
815        assert_eq!(d.reason_tag(), "top:worker:user_agent:hard");
816    }
817
818    #[test]
819    fn context_kind_label_is_stable() {
820        assert_eq!(ContextKind::Top.label(), "top");
821        assert_eq!(ContextKind::Iframe.label(), "iframe");
822        assert_eq!(ContextKind::Worker.label(), "worker");
823    }
824
825    #[test]
826    fn context_observation_accessors() {
827        let o = ContextObservation::observed(IdentitySurface::default());
828        assert!(o.is_observed());
829        assert!(!o.is_skipped());
830        assert!(o.surface().is_some());
831
832        let s = ContextObservation::skipped("nope");
833        assert!(s.is_skipped());
834        assert!(!s.is_observed());
835        assert!(s.surface().is_none());
836    }
837
838    #[test]
839    fn empty_surface_reports_empty() {
840        let s = IdentitySurface::default();
841        assert!(s.is_empty());
842        let full = surface_a();
843        assert!(!full.is_empty());
844    }
845
846    #[test]
847    fn json_roundtrip_preserves_report() {
848        let report = build_report(
849            ContextObservation::observed(surface_a()),
850            ContextObservation::observed(surface_a()),
851            ContextObservation::skipped("Worker unsupported"),
852            None,
853        );
854        let json = serde_json::to_string(&report).expect("serialize");
855        let back: CoherenceDriftReport = serde_json::from_str(&json).expect("deserialize");
856        assert_eq!(report, back);
857    }
858}