//! Tests for the failure-dump types and helpers.
//!
//! Split out of mod.rs so the data-shape and dispatch logic in mod.rs stay
//! focused on production code; assertions live here.

use super::super::bpf_map::{
    BPF_MAP_TYPE_BLOOM_FILTER, BPF_MAP_TYPE_CGROUP_STORAGE, BPF_MAP_TYPE_HASH,
    BPF_MAP_TYPE_INSN_ARRAY, BPF_MAP_TYPE_LPM_TRIE, BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE,
    BPF_MAP_TYPE_PERCPU_HASH, BPF_MAP_TYPE_QUEUE, BPF_MAP_TYPE_STACK,
};
// `MemReader` brought into scope so the stub readers below can
// call `is_arena_addr` / `read_arena` via dot notation. The FQP
// `super::super::btf_render::MemReader` resolves the trait for
// the `impl ... for StubReader` blocks, but Rust requires the
// trait to be in scope at the method-call site — the test
// bodies invoke `r.is_arena_addr(...)` and `r.read_arena(...)`,
// which fail with E0599 ("no method named `is_arena_addr` found")
// without this `use`.
use super::*;
use crate::monitor::btf_render::MemReader;
// `name_from_str` is the shared helper that packs a `&str` into the
// `(name_bytes, name_len)` representation used by
// [`super::super::bpf_map::BpfMapInfo`]. Single source of truth in
// [`crate::monitor::test_util`] — replaces the prior local
// `map_name_bytes` copy that duplicated the logic verbatim.
use crate::monitor::test_util::name_from_str;

// Future contributor: if you hit an AmbiguousIfImpl compile error
// here after adding `derive(Default)` (or a manual Default impl) on
// DualFailureDumpReport, the rationale is in the doc comment on the
// `DualFailureDumpReport` struct in `dump::mod` (TL;DR: the `late` field is required
// by the doc invariant — the freeze coordinator only writes a
// DualFailureDumpReport after the late snapshot has been captured.
// A Default impl would produce a wrapper with an empty late report
// whose `maps`/`vcpu_regs` vectors silently lie about a successful
// capture). Construct via the struct literal with an explicit
// `late: FailureDumpReport`.
assert_not_impl_default!(DualFailureDumpReport);

#[test]
fn hex_dump_basic() {
    assert_eq!(hex_dump(&[]), "");
    assert_eq!(hex_dump(&[0]), "00");
    assert_eq!(hex_dump(&[0x12, 0x34, 0xab]), "12 34 ab");
}

/// Empty input renders as empty string. Single-element input
/// renders as one mid-tier glyph (constant non-zero series
/// reads as "no variation"). All-zero series renders as the
/// lowest glyph repeated.
#[test]
fn render_sparkline_edge_cases() {
    assert_eq!(render_sparkline(&[]), "");
    // Single non-zero element: constant series → mid-tier glyph.
    assert_eq!(render_sparkline(&[42]), "▅");
    // All-zero series: lowest glyph for every entry.
    assert_eq!(render_sparkline(&[0, 0, 0]), "▁▁▁");
    // All-equal non-zero series: mid-tier glyph for every entry.
    assert_eq!(render_sparkline(&[5, 5, 5]), "▅▅▅");
}

/// Strictly-increasing series scales linearly across the glyph
/// set: first sample at min lands at lowest glyph, last sample
/// at max lands at highest. Pin both ends so a future scaling
/// regression that broke either bound is caught.
#[test]
fn render_sparkline_monotonic_scales_to_full_range() {
    let s = render_sparkline(&[0, 1, 2, 3, 4, 5, 6, 7]);
    let chars: Vec<char> = s.chars().collect();
    assert_eq!(chars.len(), 8);
    assert_eq!(chars[0], '▁', "min must map to lowest glyph: {s}");
    assert_eq!(chars[7], '█', "max must map to highest glyph: {s}");
}

/// i64 wrapper saturates negative values to 0, then routes
/// through u64 sparkline. Verifies a counter that briefly
/// dips negative (corrupt read) doesn't crash and produces
/// a sane sparkline.
#[test]
fn render_sparkline_i64_clamps_negatives() {
    let s = render_sparkline_i64(&[-5, 0, 5, 10]);
    // After clamp: [0, 0, 5, 10] → -5 clamps to 0 (lowest glyph), 10
    // is max (highest glyph). Pin the GLYPH CONTENT, not just length:
    // dropping the `.max(0)` clamp renders `-5 as u64` = ~u64::MAX, so
    // the first glyph becomes the highest block and 5/10 collapse to
    // the lowest — still 4 chars, so a length-only check misses it.
    let chars: Vec<char> = s.chars().collect();
    assert_eq!(chars.len(), 4);
    assert_eq!(
        chars[0], '▁',
        "clamped -5 must render the lowest glyph, not the highest (clamp dropped?)",
    );
    assert_eq!(
        chars[3], '█',
        "the max value (10) must render the highest glyph"
    );
}

/// Full SCX_EV_* counter timeline construction: build a
/// MonitorSample with two CPUs reporting event counters,
/// fold to EventCounterSample, verify cross-CPU sums and
/// elapsed_ms propagation.
#[test]
fn event_counter_sample_sums_across_cpus() {
    use super::super::{CpuSnapshot, MonitorSample, ScxEventCounters};
    let cpu_a = CpuSnapshot {
        event_counters: Some(ScxEventCounters {
            select_cpu_fallback: 5,
            bypass_dispatch: 100,
            ..Default::default()
        }),
        ..Default::default()
    };
    let cpu_b = CpuSnapshot {
        event_counters: Some(ScxEventCounters {
            select_cpu_fallback: 7,
            bypass_dispatch: 50,
            ..Default::default()
        }),
        ..Default::default()
    };
    let sample = MonitorSample {
        bpf_map_fields: Vec::new(),
        elapsed_ms: 100,
        cpus: vec![cpu_a, cpu_b],
        prog_stats: None,
        psi_irq: None,
    };
    let folded = EventCounterSample::from_monitor_sample(&sample)
        .expect("at least one CPU has event_counters");
    assert_eq!(folded.elapsed_ms, 100);
    assert_eq!(folded.select_cpu_fallback, 12);
    assert_eq!(folded.bypass_dispatch, 150);
}

/// MonitorSample with no CPU reporting event_counters folds
/// to None — propagating an all-zero row would mislead the
/// downstream consumer (a real "every counter at 0" tick
/// looks identical to "every CPU's offsets unresolved").
#[test]
fn event_counter_sample_returns_none_when_no_cpu_has_counters() {
    use super::super::{CpuSnapshot, MonitorSample};
    let cpu = CpuSnapshot {
        event_counters: None,
        ..Default::default()
    };
    let sample = MonitorSample {
        bpf_map_fields: Vec::new(),
        elapsed_ms: 200,
        cpus: vec![cpu],
        prog_stats: None,
        psi_irq: None,
    };
    assert!(EventCounterSample::from_monitor_sample(&sample).is_none());
}

/// EventCounterSample serde round-trips cleanly: every field
/// is `i64` (kernel-side `s64`), so a wire-format encode →
/// decode preserves bit patterns including the i64::MAX edge.
#[test]
fn event_counter_sample_serde_roundtrip() {
    let s = EventCounterSample {
        elapsed_ms: 123_456,
        select_cpu_fallback: i64::MAX,
        insert_not_owned: -1, // kernel never produces this
        // but the wire format must
        // preserve whatever the read
        // captured rather than silently
        // clamp.
        ..Default::default()
    };
    let json = serde_json::to_string(&s).unwrap();
    let loaded: EventCounterSample = serde_json::from_str(&json).unwrap();
    assert_eq!(loaded.elapsed_ms, 123_456);
    assert_eq!(loaded.select_cpu_fallback, i64::MAX);
    assert_eq!(loaded.insert_not_owned, -1);
}

#[test]
fn report_serde_roundtrip() {
    let report = FailureDumpReport {
        schema: SCHEMA_SINGLE.to_string(),
        active_map_kvas: Vec::new(),
        maps: vec![FailureDumpMap {
            name: "scx_demo.bss".into(),
            map_kva: 0,
            map_type: BPF_MAP_TYPE_ARRAY,
            value_size: 8,
            max_entries: 1,
            value: Some(RenderedValue::Uint {
                bits: 32,
                value: 42,
            }),
            entries: Vec::new(),
            array_entries: Vec::new(),
            percpu_entries: Vec::new(),
            percpu_hash_entries: Vec::new(),
            arena: None,
            ringbuf: None,
            stack_trace: None,
            fd_array: None,
            error: None,
        }],
        vcpu_regs: Vec::new(),
        sdt_allocations: Vec::new(),
        sdt_alloc_unavailable: None,
        prog_runtime_stats: Vec::new(),
        prog_runtime_stats_unavailable: None,
        per_cpu_time: Vec::new(),
        cgroup_psi: Vec::new(),
        per_node_numa: Vec::new(),
        per_node_numa_unavailable: None,
        task_enrichments: Vec::new(),
        task_enrichments_unavailable: None,
        event_counter_timeline: Vec::new(),
        rq_scx_states: Vec::new(),
        dsq_states: Vec::new(),
        scx_sched_state: None,
        scx_walker_unavailable: None,
        vcpu_perf_at_freeze: Vec::new(),
        dump_truncated_at_us: None,
        maps_truncated: 0,
        probe_counters: None,
        scx_static_ranges: Default::default(),
        is_placeholder: false,
        active_obj_name: None,
    };
    let json = serde_json::to_string(&report).unwrap();
    let parsed: FailureDumpReport = serde_json::from_str(&json).unwrap();
    assert_eq!(parsed.maps.len(), 1);
    assert_eq!(parsed.maps[0].name, "scx_demo.bss");
    assert_eq!(parsed.maps[0].max_entries, 1);
}

#[test]
fn empty_report_serde() {
    let report = FailureDumpReport::default();
    let json = serde_json::to_string(&report).unwrap();
    let parsed: FailureDumpReport = serde_json::from_str(&json).unwrap();
    assert!(parsed.maps.is_empty());
}

/// Type-system proof that [`serde_json::to_string`] is infallible
/// for [`FailureDumpReport`]'s concrete shape, no matter how full
/// the vecs and options are populated.
///
/// Load-bearing for the
/// [`crate::vmm::freeze_coord::EarlySnapshotGuard`] Drop body's
/// "MUST NOT panic" precondition (the Drop documents
/// `serde_json::to_string` as panic-free for well-typed input). A
/// regression that introduced a field whose `Serialize` impl can
/// return an error (e.g. a `HashMap<NotEq, _>` or a custom type
/// with a fallible serializer) would fire here BEFORE landing in
/// the Drop site, where the same failure would log via
/// `tracing::error` rather than reaching disk.
///
/// The reachable subset of fields stays at empty / default for
/// types whose constructors are private to other modules — the
/// proof matters at the dispatch-site level (every populated
/// field serializes), not at the cardinality level (every
/// possible value of every field). Multiple `maps` entries +
/// multi-vCPU `vcpu_regs` mix + populated diagnostic strings +
/// `Some(ProbeBssCounters::default())` exercise the per-vec /
/// per-option serializer dispatch paths that the empty-default
/// roundtrip test does not.
#[test]
fn failure_dump_report_serialization_is_infallible_for_max_synthetic_input() {
    let report = FailureDumpReport {
        schema: SCHEMA_SINGLE.to_string(),
        active_map_kvas: Vec::new(),
        // Multiple maps: different map types, error vs value-bearing,
        // exercise the per-map serialization dispatch.
        maps: vec![
            FailureDumpMap {
                name: "synthetic_array.bss".into(),
                map_kva: 0,
                map_type: BPF_MAP_TYPE_ARRAY,
                value_size: 8,
                max_entries: 1,
                value: Some(RenderedValue::Uint {
                    bits: 32,
                    value: 0xCAFE,
                }),
                entries: Vec::new(),
                array_entries: Vec::new(),
                percpu_entries: Vec::new(),
                percpu_hash_entries: Vec::new(),
                arena: None,
                ringbuf: None,
                stack_trace: None,
                fd_array: None,
                error: None,
            },
            FailureDumpMap {
                name: "synthetic_hash.hash".into(),
                map_kva: 0,
                map_type: BPF_MAP_TYPE_HASH,
                value_size: 16,
                max_entries: 64,
                value: None,
                entries: Vec::new(),
                array_entries: Vec::new(),
                percpu_entries: Vec::new(),
                percpu_hash_entries: Vec::new(),
                arena: None,
                ringbuf: None,
                stack_trace: None,
                fd_array: None,
                error: Some("synthetic walker failure (test fixture)".into()),
            },
            FailureDumpMap {
                name: "synthetic_unsupported.queue".into(),
                map_kva: 0,
                map_type: BPF_MAP_TYPE_QUEUE,
                value_size: 4,
                max_entries: 0,
                value: None,
                entries: Vec::new(),
                array_entries: Vec::new(),
                percpu_entries: Vec::new(),
                percpu_hash_entries: Vec::new(),
                arena: None,
                ringbuf: None,
                stack_trace: None,
                fd_array: None,
                error: Some("type not supported".into()),
            },
            // Multi-entry ARRAY: exercises the `array_entries`
            // serializer dispatch (key-0 readable, key-1 None) so the
            // Drop-path panic-free precondition covers this field too.
            FailureDumpMap {
                name: "synthetic_array.cells".into(),
                map_kva: 0,
                map_type: BPF_MAP_TYPE_ARRAY,
                value_size: 8,
                max_entries: 3,
                value: None,
                entries: Vec::new(),
                array_entries: vec![
                    FailureDumpArrayEntry {
                        key: 0,
                        value: Some(RenderedValue::Uint {
                            bits: 64,
                            value: 0xBEEF,
                        }),
                    },
                    FailureDumpArrayEntry {
                        key: 1,
                        value: None,
                    },
                ],
                percpu_entries: Vec::new(),
                percpu_hash_entries: Vec::new(),
                arena: None,
                ringbuf: None,
                stack_trace: None,
                fd_array: None,
                error: None,
            },
        ],
        // Multi-vCPU mix of Some / None to stress the
        // `Vec<Option<VcpuRegSnapshot>>` serializer dispatch.
        vcpu_regs: vec![None, None, None],
        sdt_allocations: Vec::new(),
        sdt_alloc_unavailable: Some(super::REASON_SDT_ALLOC_NO_INSTANCE.into()),
        prog_runtime_stats: Vec::new(),
        prog_runtime_stats_unavailable: Some(super::REASON_PROG_ACCESSOR_UNAVAILABLE.into()),
        per_cpu_time: vec![PerCpuTimeStats::default(); 4],
        cgroup_psi: Vec::new(),
        per_node_numa: vec![PerNodeNumaStats::default(); 2],
        per_node_numa_unavailable: Some(super::REASON_NO_NUMA_WALKER.into()),
        task_enrichments: Vec::new(),
        task_enrichments_unavailable: Some(super::REASON_NO_TASK_WALKER.into()),
        event_counter_timeline: vec![EventCounterSample::default(); 8],
        rq_scx_states: Vec::new(),
        dsq_states: Vec::new(),
        scx_sched_state: None,
        scx_walker_unavailable: Some(super::REASON_NO_SCX_WALKER.into()),
        vcpu_perf_at_freeze: vec![None, None, None],
        dump_truncated_at_us: Some(12_345),
        maps_truncated: 0,
        probe_counters: Some(ProbeBssCounters::default()),
        scx_static_ranges: Default::default(),
        is_placeholder: false,
        active_obj_name: None,
    };

    let result = serde_json::to_string(&report);
    assert!(
        result.is_ok(),
        "FailureDumpReport serialization MUST be infallible for max-synthetic input \
         (see EarlySnapshotGuard Drop body's panic-free precondition); error: {:?}",
        result.err()
    );
    let json = result.unwrap();
    assert!(
        json.len() > 200,
        "max-synthetic JSON should be non-trivial; got {} bytes",
        json.len()
    );

    // Roundtrip leg confirms the populated fields survive a
    // serialize → deserialize cycle without losing the
    // diagnostic strings or counts.
    let parsed: FailureDumpReport =
        serde_json::from_str(&json).expect("max-synthetic JSON must deserialize cleanly");
    assert_eq!(parsed.maps.len(), 4);
    assert_eq!(parsed.vcpu_regs.len(), 3);
    assert_eq!(parsed.per_cpu_time.len(), 4);
    assert_eq!(parsed.per_node_numa.len(), 2);
    assert_eq!(parsed.event_counter_timeline.len(), 8);
    assert_eq!(parsed.vcpu_perf_at_freeze.len(), 3);
    assert_eq!(parsed.dump_truncated_at_us, Some(12_345));
    assert!(parsed.probe_counters.is_some());
    assert!(parsed.sdt_alloc_unavailable.is_some());
    assert!(parsed.task_enrichments_unavailable.is_some());
}

/// Type-system extension of the infallible-serialization proof to
/// the wrapper. `DualFailureDumpReport` adds 5 fields beyond a
/// nested `FailureDumpReport` (schema: String, early:
/// Option<FailureDumpReport>, late: FailureDumpReport,
/// early_max_age_jiffies: u64, early_threshold_jiffies: u64,
/// early_skipped_reason: Option<String>). String, Option<T>, u64
/// all have std-derived infallible Serialize impls, so by Serialize
/// composition the wrapper is infallible iff FailureDumpReport is.
/// The sibling
/// `failure_dump_report_serialization_is_infallible_for_max_synthetic_input`
/// proves the inner case; this test proves the composition at the
/// dispatch level so a future field added to DualFailureDumpReport
/// with a fallible Serialize impl trips here BEFORE silently
/// regressing the `EarlySnapshotGuard::drain_to_disk` eager-take
/// safety.
///
/// u64::MAX + repeated-String stress catches future Serialize impls
/// that special-case extreme values or assume bounded buffers.
///
/// Deliberately exercises Some/Some on `early` + `early_skipped_reason`
/// simultaneously to maximize serializer dispatch coverage. Production
/// invariant is that these are mutually-exclusive (Some-early ↔
/// None-reason, per the DualFailureDumpReport doc + the freeze
/// coordinator's late-trigger Captured arm); the test deliberately
/// violates the invariant because the goal here is Serialize dispatch
/// coverage, not invariant enforcement. A future test that pins the
/// mutual exclusion can live separately.
#[test]
fn dual_failure_dump_report_serialization_is_infallible_for_max_synthetic_input() {
    fn max_inner() -> FailureDumpReport {
        FailureDumpReport {
            schema: SCHEMA_SINGLE.to_string(),
            active_map_kvas: Vec::new(),
            maps: vec![FailureDumpMap {
                name: "synthetic_array.bss".into(),
                map_kva: 0,
                map_type: BPF_MAP_TYPE_ARRAY,
                value_size: 8,
                max_entries: 1,
                value: Some(RenderedValue::Uint {
                    bits: 32,
                    value: 0xCAFE,
                }),
                entries: Vec::new(),
                array_entries: Vec::new(),
                percpu_entries: Vec::new(),
                percpu_hash_entries: Vec::new(),
                arena: None,
                ringbuf: None,
                stack_trace: None,
                fd_array: None,
                error: None,
            }],
            vcpu_regs: vec![None, None],
            sdt_allocations: Vec::new(),
            sdt_alloc_unavailable: Some(super::REASON_SDT_ALLOC_NO_INSTANCE.into()),
            prog_runtime_stats: Vec::new(),
            prog_runtime_stats_unavailable: Some(super::REASON_PROG_ACCESSOR_UNAVAILABLE.into()),
            per_cpu_time: vec![PerCpuTimeStats::default(); 2],
            cgroup_psi: Vec::new(),
            per_node_numa: vec![PerNodeNumaStats::default(); 1],
            per_node_numa_unavailable: Some(super::REASON_NO_NUMA_WALKER.into()),
            task_enrichments: Vec::new(),
            task_enrichments_unavailable: Some(super::REASON_NO_TASK_WALKER.into()),
            event_counter_timeline: vec![EventCounterSample::default(); 4],
            rq_scx_states: Vec::new(),
            dsq_states: Vec::new(),
            scx_sched_state: None,
            scx_walker_unavailable: Some(super::REASON_NO_SCX_WALKER.into()),
            vcpu_perf_at_freeze: vec![None, None],
            dump_truncated_at_us: Some(7_777),
            maps_truncated: 0,
            probe_counters: Some(ProbeBssCounters::default()),
            scx_static_ranges: Default::default(),
            is_placeholder: false,
            active_obj_name: None,
        }
    }

    let dual = DualFailureDumpReport {
        schema: SCHEMA_DUAL.to_string(),
        early: Some(max_inner()),
        late: max_inner(),
        early_max_age_jiffies: u64::MAX,
        early_threshold_jiffies: u64::MAX,
        early_skipped_reason: Some(
            "max synthetic reason — long enough to stress the String serializer path ".repeat(64),
        ),
    };

    let result = serde_json::to_string(&dual);
    assert!(
        result.is_ok(),
        "DualFailureDumpReport serialization MUST be infallible for max-synthetic input \
         (see EarlySnapshotGuard Drop body's panic-free precondition + the eager-take \
         doc at src/vmm/freeze_coord/mod.rs Captured arm); error: {:?}",
        result.err()
    );
    let json = result.unwrap();
    assert!(
        json.len() > 500,
        "max-synthetic Dual JSON should be substantial; got {} bytes",
        json.len()
    );

    let parsed: DualFailureDumpReport =
        serde_json::from_str(&json).expect("max-synthetic Dual JSON must deserialize cleanly");
    assert_eq!(parsed.schema, SCHEMA_DUAL);
    assert!(parsed.early.is_some(), "early field must round-trip Some");
    assert_eq!(parsed.early_max_age_jiffies, u64::MAX);
    assert_eq!(parsed.early_threshold_jiffies, u64::MAX);
    assert!(
        parsed.early_skipped_reason.is_some(),
        "early_skipped_reason must round-trip Some"
    );
}

/// Pins the canonical [`ALL_SNAPSHOT_TAGS`] slice against drift.
/// A new `SNAPSHOT_TAG_*` pub const added without updating the
/// slice would silently leave negative-scan tests under-covering
/// the new tag. This test asserts:
///   (a) each individual SNAPSHOT_TAG_* const appears in the slice,
///   (b) the slice length matches the expected count, AND
///   (c) all entries are distinct (no accidental duplicates).
///
/// Mechanism: Rust has no reflection, so the test must enumerate
/// every pub const by name. A new SNAPSHOT_TAG_* addition requires
/// updating this test in lockstep with the slice — the length
/// assertion at (b) is the primary failure signal when a new const
/// lands without a slice update.
///
/// Alternative considered: build.rs codegen that scans dump/mod.rs
/// for `pub const SNAPSHOT_TAG_` and generates the slice at compile
/// time. Rejected as over-engineered for a 4-element list that
/// changes < 1x/year; the hand-maintained slice + this pinning test
/// gives the same safety with no build-time complexity.
#[test]
fn all_snapshot_tags_enumerates_every_pub_const_in_module() {
    // Defense-in-depth: a regression that emptied
    // both ALL_SNAPSHOT_TAGS AND the expected list would pass the
    // length-equality check below vacuously. Explicit minimum-size
    // floor catches that regression class — any future shrink past
    // the original 4-tag baseline trips this assertion first.
    assert!(
        ALL_SNAPSHOT_TAGS.len() >= 4,
        "ALL_SNAPSHOT_TAGS shrank below 4 entries — every tag in \
         this module's pub const SNAPSHOT_TAG_* surface should be \
         in the slice; a shrink suggests an unintended removal"
    );

    let expected: &[&str] = &[
        SNAPSHOT_TAG_EARLY_ONLY_LATE_NEVER_FIRED,
        SNAPSHOT_TAG_EARLY_ONLY_LATE_SUPPRESSED,
        SNAPSHOT_TAG_EARLY_PRE_LATE_DEGRADED,
        SNAPSHOT_TAG_EARLY_DEGRADED,
    ];
    for tag in expected {
        assert!(
            ALL_SNAPSHOT_TAGS.contains(tag),
            "SNAPSHOT_TAG_* constant {tag:?} missing from ALL_SNAPSHOT_TAGS — \
             add it to the slice in src/monitor/dump/mod.rs"
        );
    }
    assert_eq!(
        ALL_SNAPSHOT_TAGS.len(),
        expected.len(),
        "ALL_SNAPSHOT_TAGS length mismatch with hand-enumerated set — \
         either a new SNAPSHOT_TAG_* const was added without updating \
         this test, or vice versa"
    );
    let mut sorted: Vec<&str> = ALL_SNAPSHOT_TAGS.to_vec();
    sorted.sort();
    sorted.dedup();
    assert_eq!(
        sorted.len(),
        ALL_SNAPSHOT_TAGS.len(),
        "ALL_SNAPSHOT_TAGS contains duplicate entries — every tag value must be unique"
    );
}

// ---- Display impl coverage --------------------------------------
//
// The Display impl is the human-readable form used in test
// failure output. Pin its layout against representative shapes.

fn make_simple_map() -> FailureDumpMap {
    FailureDumpMap {
        name: "scx_demo.bss".into(),
        map_kva: 0,
        map_type: BPF_MAP_TYPE_ARRAY,
        value_size: 8,
        max_entries: 1,
        value: Some(RenderedValue::Struct {
            type_name: Some("task_ctx".into()),
            members: vec![super::super::btf_render::RenderedMember {
                name: "weight".into(),
                value: RenderedValue::Uint {
                    bits: 32,
                    value: 1024,
                },
            }],
        }),
        entries: Vec::new(),
        array_entries: Vec::new(),
        percpu_entries: Vec::new(),
        percpu_hash_entries: Vec::new(),
        arena: None,
        ringbuf: None,
        stack_trace: None,
        fd_array: None,
        error: None,
    }
}

#[test]
fn report_display_empty() {
    let report = FailureDumpReport::default();
    assert_eq!(format!("{report}"), "(empty failure dump)");
}

#[test]
fn report_display_truncation_only_does_not_say_empty_dump() {
    // A dump that dropped maps to deadline truncation but has no
    // other captured sections must surface the truncation footer
    // rather than fall through to "(empty failure dump)" — otherwise
    // a degraded dump reads as "nothing to show" instead of
    // "everything was skipped".
    let report = FailureDumpReport {
        dump_truncated_at_us: Some(31_000),
        maps_truncated: 4,
        ..Default::default()
    };
    let out = format!("{report}");
    assert_eq!(
        out,
        "dump truncated: deadline crossed at 31000us (4 map(s) skipped)"
    );
}

#[test]
fn report_display_appends_truncation_footer_after_sections() {
    // When sections render AND truncation occurred, the footer is a
    // separate trailing block.
    let report = FailureDumpReport {
        vcpu_regs: vec![None],
        maps_truncated: 2,
        ..Default::default()
    };
    let out = format!("{report}");
    assert_eq!(
        out,
        "vcpu_regs:\n  vcpu 0: <unavailable>\n\ndump truncated: (2 map(s) skipped)"
    );
}

#[test]
fn report_display_one_map_with_value() {
    let report = FailureDumpReport {
        schema: SCHEMA_SINGLE.to_string(),
        active_map_kvas: Vec::new(),
        maps: vec![make_simple_map()],
        vcpu_regs: Vec::new(),
        sdt_allocations: Vec::new(),
        sdt_alloc_unavailable: None,
        prog_runtime_stats: Vec::new(),
        prog_runtime_stats_unavailable: None,
        per_cpu_time: Vec::new(),
        cgroup_psi: Vec::new(),
        per_node_numa: Vec::new(),
        per_node_numa_unavailable: None,
        task_enrichments: Vec::new(),
        task_enrichments_unavailable: None,
        event_counter_timeline: Vec::new(),
        rq_scx_states: Vec::new(),
        dsq_states: Vec::new(),
        scx_sched_state: None,
        scx_walker_unavailable: None,
        vcpu_perf_at_freeze: Vec::new(),
        dump_truncated_at_us: None,
        maps_truncated: 0,
        probe_counters: None,
        scx_static_ranges: Default::default(),
        is_placeholder: false,
        active_obj_name: None,
    };
    let out = format!("{report}");
    // Map header line.
    assert!(
        out.starts_with("map scx_demo.bss (type="),
        "missing header: {out}"
    );
    // Struct rendering: the inline form is `TypeName{f=v}` — no
    // `struct` keyword, no space before brace, `=` separator.
    assert!(out.contains("task_ctx{"), "missing struct: {out}");
    assert!(out.contains("weight=1024"), "missing member: {out}");
    assert!(out.ends_with('}'), "missing closing brace: {out}");
}

#[test]
fn report_display_multiple_maps_separated() {
    let report = FailureDumpReport {
        schema: SCHEMA_SINGLE.to_string(),
        active_map_kvas: Vec::new(),
        maps: vec![make_simple_map(), make_simple_map()],
        vcpu_regs: Vec::new(),
        sdt_allocations: Vec::new(),
        sdt_alloc_unavailable: None,
        prog_runtime_stats: Vec::new(),
        prog_runtime_stats_unavailable: None,
        per_cpu_time: Vec::new(),
        cgroup_psi: Vec::new(),
        per_node_numa: Vec::new(),
        per_node_numa_unavailable: None,
        task_enrichments: Vec::new(),
        task_enrichments_unavailable: None,
        event_counter_timeline: Vec::new(),
        rq_scx_states: Vec::new(),
        dsq_states: Vec::new(),
        scx_sched_state: None,
        scx_walker_unavailable: None,
        vcpu_perf_at_freeze: Vec::new(),
        dump_truncated_at_us: None,
        maps_truncated: 0,
        probe_counters: None,
        scx_static_ranges: Default::default(),
        is_placeholder: false,
        active_obj_name: None,
    };
    let out = format!("{report}");
    // Maps separated by a blank line (\n\n).
    let blank_line_count = out.matches("\n\n").count();
    assert_eq!(
        blank_line_count, 1,
        "expected one blank-line separator between two maps: {out}"
    );
}

#[test]
fn map_display_includes_error_marker() {
    let mut m = make_simple_map();
    m.value = None;
    m.error = Some("ARRAY value region unreadable".into());
    let out = format!("{m}");
    assert!(
        out.contains("[error: ARRAY value region unreadable]"),
        "missing error marker: {out}"
    );
}

#[test]
fn map_display_stack_trace_surfaces_unreadable_buckets() {
    let mut m = make_simple_map();
    m.value = None;
    m.stack_trace = Some(FailureDumpStackTrace {
        n_buckets: 8,
        entries: Vec::new(),
        truncated: false,
        buckets_unreadable: 3,
    });
    let out = format!("{m}");
    assert!(
        out.contains("0 of 8 buckets populated"),
        "missing bucket summary: {out}"
    );
    assert!(
        out.contains("(3 unreadable)"),
        "unreadable-bucket count must surface so the gap reads as \
         'unreadable' not 'fewer stacks': {out}"
    );
}

#[test]
fn map_display_fd_array_surfaces_unreadable_slots() {
    let mut m = make_simple_map();
    m.value = None;
    m.fd_array = Some(FailureDumpFdArray {
        populated: 1,
        scanned: 6,
        indices: vec![0],
        truncated: false,
        indices_truncated: false,
        unreadable: 2,
    });
    let out = format!("{m}");
    assert!(
        out.contains("1 of 6 slots populated"),
        "missing slot summary: {out}"
    );
    assert!(
        out.contains("(2 unreadable)"),
        "unreadable-slot count must surface so populated reads as a \
         lower bound, not a confirmed total: {out}"
    );
}

#[test]
fn entry_display_renders_key_and_value() {
    // Scalar key + value: header is `entry: key=7\n  value: 99`.
    // The `=` is the key-assignment marker; `: ` introduces the
    // value field. The renderer doesn't add type breadcrumb for
    // bare scalars.
    let entry = FailureDumpEntry {
        key: Some(RenderedValue::Uint { bits: 32, value: 7 }),
        key_hex: "07 00 00 00".into(),
        value: Some(RenderedValue::Uint {
            bits: 32,
            value: 99,
        }),
        value_hex: "63 00 00 00".into(),
        payload: None,
    };
    let out = format!("{entry}");
    assert!(out.contains("key=7"), "missing key: {out}");
    assert!(out.contains("value: 99"), "missing value: {out}");
}

#[test]
fn entry_display_falls_back_to_hex_when_no_btf() {
    // No BTF → key/value are None; Display surfaces the hex with
    // a `(raw)` marker so the operator distinguishes "no BTF
    // render" from a parsed scalar value.
    let entry = FailureDumpEntry {
        key: None,
        key_hex: "ab cd".into(),
        value: None,
        value_hex: "ef".into(),
        payload: None,
    };
    let out = format!("{entry}");
    assert!(out.contains("ab cd (raw)"), "missing key hex: {out}");
    assert!(out.contains("ef (raw)"), "missing value hex: {out}");
}

#[test]
fn array_entry_display_renders_key_and_value() {
    // `key <N>: <rendered value>` — header carries the u32 index.
    let entry = FailureDumpArrayEntry {
        key: 3,
        value: Some(RenderedValue::Uint {
            bits: 32,
            value: 42,
        }),
    };
    let out = format!("{entry}");
    assert!(out.contains("key 3:"), "missing key: {out}");
    assert!(out.contains("42"), "missing value: {out}");
}

#[test]
fn array_entry_display_marks_unreadable() {
    // A `None` value (unmapped key) renders the explicit marker so an
    // operator distinguishes "unreadable" from a zero value.
    let entry = FailureDumpArrayEntry {
        key: 9,
        value: None,
    };
    let out = format!("{entry}");
    assert!(
        out.contains("key 9: <unreadable>"),
        "missing unreadable marker: {out}"
    );
}

#[test]
fn array_entries_serde_roundtrip() {
    let map = FailureDumpMap {
        name: "scx_demo.cells".into(),
        map_kva: 0,
        map_type: BPF_MAP_TYPE_ARRAY,
        value_size: 8,
        max_entries: 3,
        value: None,
        entries: Vec::new(),
        array_entries: vec![
            FailureDumpArrayEntry {
                key: 0,
                value: Some(RenderedValue::Uint {
                    bits: 64,
                    value: 100,
                }),
            },
            FailureDumpArrayEntry {
                key: 1,
                value: Some(RenderedValue::Uint {
                    bits: 64,
                    value: 200,
                }),
            },
            FailureDumpArrayEntry {
                key: 2,
                value: None,
            },
        ],
        percpu_entries: Vec::new(),
        percpu_hash_entries: Vec::new(),
        arena: None,
        ringbuf: None,
        stack_trace: None,
        fd_array: None,
        error: None,
    };
    let json = serde_json::to_string(&map).unwrap();
    let parsed: FailureDumpMap = serde_json::from_str(&json).unwrap();
    // Multi-entry ARRAY leaves the single-entry `value` empty.
    assert!(parsed.value.is_none());
    assert_eq!(parsed.array_entries.len(), 3);
    assert_eq!(parsed.array_entries[0].key, 0);
    assert_eq!(parsed.array_entries[2].key, 2);
    assert!(
        parsed.array_entries[2].value.is_none(),
        "an unreadable key's None value must survive the roundtrip"
    );
    match &parsed.array_entries[1].value {
        Some(RenderedValue::Uint { bits, value }) => {
            assert_eq!(*bits, 64);
            assert_eq!(*value, 200);
        }
        other => panic!("expected Uint, got {other:?}"),
    }
}

#[test]
fn percpu_entry_display_shows_each_cpu() {
    let entry = FailureDumpPercpuEntry {
        key: 0,
        per_cpu: vec![
            Some(RenderedValue::Uint { bits: 32, value: 1 }),
            None,
            Some(RenderedValue::Uint { bits: 32, value: 3 }),
        ],
    };
    let out = format!("{entry}");
    assert!(out.contains("key 0:"));
    assert!(out.contains("cpu 0: 1"));
    assert!(out.contains("cpu 1: <unmapped>"));
    assert!(out.contains("cpu 2: 3"));
}

// ---- vcpu_regs Display coverage ---------------------------------

#[test]
fn report_display_includes_vcpu_regs_section() {
    let report = FailureDumpReport {
        schema: SCHEMA_SINGLE.to_string(),
        active_map_kvas: Vec::new(),
        maps: Vec::new(),
        vcpu_regs: vec![
            Some(VcpuRegSnapshot {
                instruction_pointer: 0x1,
                stack_pointer: 0x2,
                page_table_root: 0x3,
                user_page_table_root: None,
                tcr_el1: None,
            }),
            None,
            Some(VcpuRegSnapshot {
                instruction_pointer: 0xa,
                stack_pointer: 0xb,
                page_table_root: 0xc,
                user_page_table_root: None,
                tcr_el1: None,
            }),
        ],
        sdt_allocations: Vec::new(),
        sdt_alloc_unavailable: None,
        prog_runtime_stats: Vec::new(),
        prog_runtime_stats_unavailable: None,
        per_cpu_time: Vec::new(),
        cgroup_psi: Vec::new(),
        per_node_numa: Vec::new(),
        per_node_numa_unavailable: None,
        task_enrichments: Vec::new(),
        task_enrichments_unavailable: None,
        event_counter_timeline: Vec::new(),
        rq_scx_states: Vec::new(),
        dsq_states: Vec::new(),
        scx_sched_state: None,
        scx_walker_unavailable: None,
        vcpu_perf_at_freeze: Vec::new(),
        dump_truncated_at_us: None,
        maps_truncated: 0,
        probe_counters: None,
        scx_static_ranges: Default::default(),
        is_placeholder: false,
        active_obj_name: None,
    };
    let out = format!("{report}");
    // Section header.
    assert!(out.starts_with("vcpu_regs:"), "missing header: {out}");
    // Three vCPU rows: 0 with values, 1 unavailable, 2 with values.
    assert!(out.contains("vcpu 0: ip=0x"), "missing vcpu 0: {out}");
    assert!(
        out.contains("vcpu 1: <unavailable>"),
        "missing vcpu 1 marker: {out}"
    );
    assert!(out.contains("vcpu 2: ip=0x"), "missing vcpu 2: {out}");
}

#[test]
fn report_display_pairs_maps_and_vcpu_regs_with_blank_line() {
    let report = FailureDumpReport {
        schema: SCHEMA_SINGLE.to_string(),
        active_map_kvas: Vec::new(),
        maps: vec![make_simple_map()],
        vcpu_regs: vec![Some(VcpuRegSnapshot {
            instruction_pointer: 0x1,
            stack_pointer: 0x2,
            page_table_root: 0x3,
            user_page_table_root: None,
            tcr_el1: None,
        })],
        sdt_allocations: Vec::new(),
        sdt_alloc_unavailable: None,
        prog_runtime_stats: Vec::new(),
        prog_runtime_stats_unavailable: None,
        per_cpu_time: Vec::new(),
        cgroup_psi: Vec::new(),
        per_node_numa: Vec::new(),
        per_node_numa_unavailable: None,
        task_enrichments: Vec::new(),
        task_enrichments_unavailable: None,
        event_counter_timeline: Vec::new(),
        rq_scx_states: Vec::new(),
        dsq_states: Vec::new(),
        scx_sched_state: None,
        scx_walker_unavailable: None,
        vcpu_perf_at_freeze: Vec::new(),
        dump_truncated_at_us: None,
        maps_truncated: 0,
        probe_counters: None,
        scx_static_ranges: Default::default(),
        is_placeholder: false,
        active_obj_name: None,
    };
    let out = format!("{report}");
    // Map block, blank line, vcpu_regs section.
    assert!(out.contains("\n\nvcpu_regs:"));
}

#[test]
fn report_display_empty_with_only_vcpu_regs_does_not_say_empty_dump() {
    // An all-empty maps Vec but populated vcpu_regs must still
    // render rather than fall through to "(empty failure dump)".
    let report = FailureDumpReport {
        schema: SCHEMA_SINGLE.to_string(),
        active_map_kvas: Vec::new(),
        maps: Vec::new(),
        vcpu_regs: vec![None],
        sdt_allocations: Vec::new(),
        sdt_alloc_unavailable: None,
        prog_runtime_stats: Vec::new(),
        prog_runtime_stats_unavailable: None,
        per_cpu_time: Vec::new(),
        cgroup_psi: Vec::new(),
        per_node_numa: Vec::new(),
        per_node_numa_unavailable: None,
        task_enrichments: Vec::new(),
        task_enrichments_unavailable: None,
        event_counter_timeline: Vec::new(),
        rq_scx_states: Vec::new(),
        dsq_states: Vec::new(),
        scx_sched_state: None,
        scx_walker_unavailable: None,
        vcpu_perf_at_freeze: Vec::new(),
        dump_truncated_at_us: None,
        maps_truncated: 0,
        probe_counters: None,
        scx_static_ranges: Default::default(),
        is_placeholder: false,
        active_obj_name: None,
    };
    let out = format!("{report}");
    assert_eq!(out, "vcpu_regs:\n  vcpu 0: <unavailable>");
}

/// Pin the wire shape of a partial dump — the
/// "all_parked but dump prerequisites unavailable" branch in
/// `vmm::run_vm`'s freeze coordinator builds exactly this
/// shape: empty `maps`, populated `vcpu_regs`. Operators
/// reading the JSON / Display output rely on:
///   - Display NOT rendering the "(empty failure dump)"
///     fallback (which would mask the partial),
///   - Display starting with the `vcpu_regs:` section,
///   - JSON serialising `"maps":[]` (NOT skipped, since
///     `Vec::is_empty` is the skip condition only for
///     `vcpu_regs` and a few `Option`/`Vec` fields inside
///     `FailureDumpMap`, not for the top-level `maps` field).
#[test]
fn report_display_partial_with_populated_regs_and_empty_maps() {
    let report = FailureDumpReport {
        schema: SCHEMA_SINGLE.to_string(),
        active_map_kvas: Vec::new(),
        maps: Vec::new(),
        vcpu_regs: vec![Some(VcpuRegSnapshot {
            instruction_pointer: 0xdead,
            stack_pointer: 0xbeef,
            page_table_root: 0xcafe,
            user_page_table_root: None,
            tcr_el1: None,
        })],
        sdt_allocations: Vec::new(),
        sdt_alloc_unavailable: None,
        prog_runtime_stats: Vec::new(),
        prog_runtime_stats_unavailable: None,
        per_cpu_time: Vec::new(),
        cgroup_psi: Vec::new(),
        per_node_numa: Vec::new(),
        per_node_numa_unavailable: None,
        task_enrichments: Vec::new(),
        task_enrichments_unavailable: None,
        event_counter_timeline: Vec::new(),
        rq_scx_states: Vec::new(),
        dsq_states: Vec::new(),
        scx_sched_state: None,
        scx_walker_unavailable: None,
        vcpu_perf_at_freeze: Vec::new(),
        dump_truncated_at_us: None,
        maps_truncated: 0,
        probe_counters: None,
        scx_static_ranges: Default::default(),
        is_placeholder: false,
        active_obj_name: None,
    };

    // (a) Display: vcpu_regs section present, no fallback.
    let out = format!("{report}");
    assert!(
        out.contains("vcpu_regs:"),
        "Display must contain the vcpu_regs section: {out}"
    );
    assert!(
        out.contains("vcpu 0: ip=0x"),
        "Display must render the BSP register row: {out}"
    );
    assert!(
        !out.contains("(empty failure dump)"),
        "Display must NOT fall through to empty fallback when \
         vcpu_regs is populated: {out}"
    );

    // (b) JSON: maps key present as empty array, NOT
    // skipped — operators downstream reliably distinguish
    // "no maps captured (partial)" from "maps key absent
    // (regression / older schema)".
    let json = serde_json::to_string(&report).expect("serialize");
    assert!(
        json.contains("\"maps\":[]"),
        "JSON must carry empty `maps` array (not skip): {json}"
    );
    assert!(
        json.contains("\"vcpu_regs\""),
        "JSON must carry vcpu_regs key: {json}"
    );
}

// -- DualFailureDumpReport serde + Display tests --

/// Roundtrip a `DualFailureDumpReport` with a populated early
/// snapshot and non-zero metric/threshold fields. Pins the wire
/// format on the dual-snapshot side: the wrapper deserialises
/// back with `early` present and the jiffies fields preserved.
#[test]
fn dual_report_serde_roundtrip_with_early() {
    let early = FailureDumpReport {
        schema: SCHEMA_SINGLE.to_string(),
        active_map_kvas: Vec::new(),
        maps: Vec::new(),
        vcpu_regs: vec![None],
        sdt_allocations: Vec::new(),
        sdt_alloc_unavailable: None,
        prog_runtime_stats: Vec::new(),
        prog_runtime_stats_unavailable: None,
        per_cpu_time: Vec::new(),
        cgroup_psi: Vec::new(),
        per_node_numa: Vec::new(),
        per_node_numa_unavailable: None,
        task_enrichments: Vec::new(),
        task_enrichments_unavailable: None,
        event_counter_timeline: Vec::new(),
        rq_scx_states: Vec::new(),
        dsq_states: Vec::new(),
        scx_sched_state: None,
        scx_walker_unavailable: None,
        vcpu_perf_at_freeze: Vec::new(),
        dump_truncated_at_us: None,
        maps_truncated: 0,
        probe_counters: None,
        scx_static_ranges: Default::default(),
        is_placeholder: false,
        active_obj_name: None,
    };
    let late = FailureDumpReport {
        schema: SCHEMA_SINGLE.to_string(),
        active_map_kvas: Vec::new(),
        maps: Vec::new(),
        vcpu_regs: vec![None, None],
        sdt_allocations: Vec::new(),
        sdt_alloc_unavailable: None,
        prog_runtime_stats: Vec::new(),
        prog_runtime_stats_unavailable: None,
        per_cpu_time: Vec::new(),
        cgroup_psi: Vec::new(),
        per_node_numa: Vec::new(),
        per_node_numa_unavailable: None,
        task_enrichments: Vec::new(),
        task_enrichments_unavailable: None,
        event_counter_timeline: Vec::new(),
        rq_scx_states: Vec::new(),
        dsq_states: Vec::new(),
        scx_sched_state: None,
        scx_walker_unavailable: None,
        vcpu_perf_at_freeze: Vec::new(),
        dump_truncated_at_us: None,
        maps_truncated: 0,
        probe_counters: None,
        scx_static_ranges: Default::default(),
        is_placeholder: false,
        active_obj_name: None,
    };
    let dual = DualFailureDumpReport {
        schema: SCHEMA_DUAL.to_string(),
        early: Some(early),
        late,
        early_max_age_jiffies: 1234,
        early_threshold_jiffies: 600,
        early_skipped_reason: None,
    };
    let json = serde_json::to_string(&dual).unwrap();
    let parsed: DualFailureDumpReport = serde_json::from_str(&json).unwrap();
    assert_eq!(parsed.schema, SCHEMA_DUAL);
    assert!(parsed.early.is_some(), "early must roundtrip: {json}");
    assert_eq!(parsed.early_max_age_jiffies, 1234);
    assert_eq!(parsed.early_threshold_jiffies, 600);
    assert_eq!(parsed.late.vcpu_regs.len(), 2);
}

/// Zero `early_max_age_jiffies` / `early_threshold_jiffies`
/// must be skipped on serialize (per the
/// `skip_serializing_if = is_zero_u64` attributes). Pinning
/// this keeps the JSON tight when the early snapshot did not
/// fire — a `late`-only run yields a wrapper without the
/// trigger-metric noise.
#[test]
fn dual_report_serde_skips_zero_jiffies_fields() {
    let dual = DualFailureDumpReport {
        schema: SCHEMA_DUAL.to_string(),
        early: None,
        late: FailureDumpReport::default(),
        early_max_age_jiffies: 0,
        early_threshold_jiffies: 0,
        early_skipped_reason: None,
    };
    let json = serde_json::to_string(&dual).unwrap();
    assert!(
        !json.contains("early_max_age_jiffies"),
        "zero early_max_age_jiffies must skip: {json}"
    );
    assert!(
        !json.contains("early_threshold_jiffies"),
        "zero early_threshold_jiffies must skip: {json}"
    );
}

/// Non-zero jiffies fields must serialize so a downstream
/// consumer can recover the trigger condition without
/// recomputing kernel arithmetic. Mirror of the
/// `skips_zero_jiffies_fields` test.
#[test]
fn dual_report_serde_emits_nonzero_jiffies_fields() {
    let dual = DualFailureDumpReport {
        schema: SCHEMA_DUAL.to_string(),
        early: Some(FailureDumpReport::default()),
        late: FailureDumpReport::default(),
        early_max_age_jiffies: 4096,
        early_threshold_jiffies: 2048,
        early_skipped_reason: None,
    };
    let json = serde_json::to_string(&dual).unwrap();
    assert!(
        json.contains("\"early_max_age_jiffies\":4096"),
        "non-zero max_age must serialize: {json}"
    );
    assert!(
        json.contains("\"early_threshold_jiffies\":2048"),
        "non-zero threshold must serialize: {json}"
    );
}

/// The `schema` field is the wire-format discriminant.
/// `FailureDumpReport` carries `"single"`,
/// `DualFailureDumpReport` carries `"dual"`, and the two
/// values are distinguishable so a consumer can inspect a
/// single field before deciding which type to deserialize
/// into.
#[test]
fn dual_report_schema_distinguishes_from_single() {
    let single = FailureDumpReport::default();
    let single_json = serde_json::to_string(&single).unwrap();
    assert!(
        single_json.contains(&format!("\"schema\":\"{SCHEMA_SINGLE}\"")),
        "single carries schema='single': {single_json}"
    );

    let dual = DualFailureDumpReport {
        schema: SCHEMA_DUAL.to_string(),
        early: None,
        late: FailureDumpReport::default(),
        early_max_age_jiffies: 0,
        early_threshold_jiffies: 0,
        early_skipped_reason: None,
    };
    let dual_json = serde_json::to_string(&dual).unwrap();
    assert!(
        dual_json.contains(&format!("\"schema\":\"{SCHEMA_DUAL}\"")),
        "dual carries schema='dual': {dual_json}"
    );
    // The two discriminants are distinct strings — a consumer
    // checking the field can tell the variants apart without
    // attempting deserialization first.
    assert_ne!(SCHEMA_SINGLE, SCHEMA_DUAL);
}

/// Display output for the early=present branch carries the
/// summary header AND the jiffies metadata, so an operator
/// scanning a log can see at a glance whether the early
/// snapshot fired and what trigger condition produced it.
#[test]
fn dual_report_display_present_carries_jiffies() {
    let dual = DualFailureDumpReport {
        schema: SCHEMA_DUAL.to_string(),
        early: Some(FailureDumpReport::default()),
        late: FailureDumpReport::default(),
        early_max_age_jiffies: 9001,
        early_threshold_jiffies: 4500,
        early_skipped_reason: None,
    };
    let s = format!("{dual}");
    assert!(
        s.contains("early=present"),
        "Display must say early=present: {s}"
    );
    assert!(
        s.contains("max_age=9001j"),
        "Display must surface max_age: {s}"
    );
    assert!(
        s.contains("threshold=4500j"),
        "Display must surface threshold: {s}"
    );
}

/// Display output for the early=absent branch carries the
/// summary header AND the documented absence-reason text
/// describing both possible causes (stall fired before the
/// half-way threshold; runnable_at scan setup failed) AND a
/// pointer to the RUST_LOG knob that surfaces scan-resolution
/// diagnostics — so an operator reading "early=absent" knows
/// the next debugging step rather than having to guess.
#[test]
fn dual_report_display_absent_names_both_causes() {
    let dual = DualFailureDumpReport {
        schema: SCHEMA_DUAL.to_string(),
        early: None,
        late: FailureDumpReport::default(),
        early_max_age_jiffies: 0,
        early_threshold_jiffies: 0,
        early_skipped_reason: None,
    };
    let s = format!("{dual}");
    assert!(
        s.contains("early=absent"),
        "Display must say early=absent: {s}"
    );
    assert!(
        s.contains("stall fired before half-way threshold"),
        "Display must name the threshold-not-reached cause: {s}"
    );
    assert!(
        s.contains("runnable_at scan setup failed"),
        "Display must name the scan-setup-failure cause: {s}"
    );
    assert!(
        s.contains("RUST_LOG=ktstr=debug"),
        "Display must point at the RUST_LOG knob for diagnostics: {s}"
    );
}

// -- FailureDumpReportAny serde + Display tests --

/// `FailureDumpReportAny::from_json` picks the `Single` variant
/// for JSON whose `schema` field is `"single"`, the `Dual`
/// variant for `"dual"`, and the `Degraded` variant for
/// `"degraded"`. An absent `schema` field returns `None` (the
/// discriminant is required — silently routing absent-schema to
/// Single would mis-render a degraded or future-variant dump as
/// a lossy single shape and hide the WHY-degraded surface from
/// the operator). Unknown schemas return `None`. Malformed JSON
/// also returns `None`.
#[test]
fn report_any_dispatch_branches() {
    // Single branch: schema="single".
    let single = FailureDumpReport::default();
    let single_json = serde_json::to_string(&single).expect("serialize single");
    match FailureDumpReportAny::from_json(&single_json) {
        Some(FailureDumpReportAny::Single(_)) => {}
        other => panic!(
            "schema=single must map to Single, got {other:?}",
            other = other.is_some()
        ),
    }

    // Dual branch: schema="dual".
    let dual = DualFailureDumpReport {
        schema: SCHEMA_DUAL.to_string(),
        early: None,
        late: FailureDumpReport::default(),
        early_max_age_jiffies: 0,
        early_threshold_jiffies: 0,
        early_skipped_reason: None,
    };
    let dual_json = serde_json::to_string(&dual).expect("serialize dual");
    match FailureDumpReportAny::from_json(&dual_json) {
        Some(FailureDumpReportAny::Dual(_)) => {}
        other => panic!(
            "schema=dual must map to Dual, got {other:?}",
            other = other.is_some()
        ),
    }

    // Degraded branch: schema="degraded".
    let degraded = super::DegradedFailureDumpReport {
        schema: super::SCHEMA_DEGRADED.to_string(),
        reason: super::REASON_DEGRADED_RENDEZVOUS_TIMEOUT.to_string(),
        vcpu_regs: Vec::new(),
        watchpoint_hit: false,
        bss_latch_state: "not_resolved".to_string(),
        exit_kind: None,
        elapsed_ms: 0,
    };
    let degraded_json = serde_json::to_string(&degraded).expect("serialize degraded");
    match FailureDumpReportAny::from_json(&degraded_json) {
        Some(FailureDumpReportAny::Degraded(_)) => {}
        other => panic!(
            "schema=degraded must map to Degraded, got {other:?}",
            other = other.is_some()
        ),
    }

    // Absent-schema branch: required discriminant, must return None.
    let absent = r#"{"maps":[],"vcpu_regs":[],"sdt_allocations":[]}"#;
    assert!(
        FailureDumpReportAny::from_json(absent).is_none(),
        "absent schema must return None (discriminant is required)"
    );

    // Unknown schema → None, not a silent single fallback.
    let unknown = r#"{"schema":"triple","maps":[],"vcpu_regs":[],"sdt_allocations":[]}"#;
    assert!(
        FailureDumpReportAny::from_json(unknown).is_none(),
        "unknown schema must return None, not silent fallback"
    );

    // Malformed JSON → None.
    assert!(
        FailureDumpReportAny::from_json("not json").is_none(),
        "garbage input must return None"
    );
}

/// Round-trip pin for [`DegradedFailureDumpReport`]: serialize a
/// synthetic instance with every field populated, parse it back via
/// the schema-dispatcher, and assert field-by-field equality. Pins
/// the wire-format stability of the degraded variant — adding a new
/// field requires an explicit test update so an accidental field
/// rename or serde-attribute mistake can't silently drift the
/// schema.
#[test]
fn degraded_report_roundtrip_field_equality() {
    use crate::vmm::exit_dispatch::VcpuRegSnapshot;
    let original = super::DegradedFailureDumpReport {
        schema: super::SCHEMA_DEGRADED.to_string(),
        reason: format!(
            "{}: 30000ms; 1/4 vCPUs parked",
            super::REASON_DEGRADED_RENDEZVOUS_TIMEOUT
        ),
        vcpu_regs: vec![
            Some(VcpuRegSnapshot {
                instruction_pointer: 0xffff_ffff_8100_1000,
                stack_pointer: 0xffff_8880_0010_0000,
                page_table_root: 0x0000_0000_4000_0000,
                user_page_table_root: None,
                tcr_el1: None,
            }),
            None,
            None,
            None,
        ],
        watchpoint_hit: true,
        bss_latch_state: "triggered".to_string(),
        exit_kind: Some(1024),
        elapsed_ms: 30_120,
    };
    let json = serde_json::to_string(&original).expect("serialize degraded");
    let parsed = match FailureDumpReportAny::from_json(&json) {
        Some(FailureDumpReportAny::Degraded(d)) => d,
        other => panic!(
            "degraded roundtrip must map to Degraded, got {other:?}",
            other = other.is_some()
        ),
    };
    assert_eq!(parsed.schema, super::SCHEMA_DEGRADED);
    assert_eq!(parsed.reason, original.reason);
    assert_eq!(parsed.vcpu_regs.len(), 4);
    assert_eq!(
        parsed.vcpu_regs[0].as_ref().map(|r| r.instruction_pointer),
        Some(0xffff_ffff_8100_1000)
    );
    assert!(parsed.vcpu_regs[1].is_none());
    assert!(parsed.vcpu_regs[2].is_none());
    assert!(parsed.vcpu_regs[3].is_none());
    assert!(parsed.watchpoint_hit);
    assert_eq!(parsed.bss_latch_state, "triggered");
    assert_eq!(parsed.exit_kind, Some(1024));
    assert_eq!(parsed.elapsed_ms, 30_120);
}

/// `prog_runtime_stats` populates and round-trips through
/// `FailureDumpReportAny::from_json`. The dispatch test above
/// covers the empty-stats path; this test pins that the field
/// survives wire encoding when populated, mirroring the
/// strict-schema concerns CgroupStats covers in assert.rs.
#[test]
fn report_any_preserves_prog_runtime_stats() {
    use super::super::bpf_prog::ProgRuntimeStats;
    let report = FailureDumpReport {
        prog_runtime_stats: vec![
            ProgRuntimeStats {
                name: "ktstr_enqueue".to_string(),
                cnt: 1_500,
                nsecs: 7_500_000,
                misses: 2,
            },
            ProgRuntimeStats {
                name: "ktstr_dispatch".to_string(),
                cnt: u64::MAX,
                nsecs: u64::MAX,
                misses: u64::MAX,
            },
        ],
        ..Default::default()
    };
    let json = serde_json::to_string(&report).expect("serialize");
    match FailureDumpReportAny::from_json(&json) {
        Some(FailureDumpReportAny::Single(loaded)) => {
            assert_eq!(loaded.prog_runtime_stats.len(), 2);
            assert_eq!(loaded.prog_runtime_stats[0].name, "ktstr_enqueue");
            assert_eq!(loaded.prog_runtime_stats[0].cnt, 1_500);
            assert_eq!(loaded.prog_runtime_stats[0].nsecs, 7_500_000);
            assert_eq!(loaded.prog_runtime_stats[0].misses, 2);
            assert_eq!(loaded.prog_runtime_stats[1].name, "ktstr_dispatch");
            assert_eq!(loaded.prog_runtime_stats[1].cnt, u64::MAX);
            assert_eq!(loaded.prog_runtime_stats[1].nsecs, u64::MAX);
            assert_eq!(loaded.prog_runtime_stats[1].misses, u64::MAX);
        }
        other => panic!(
            "populated single report must round-trip Single, got {:?}",
            other.is_some()
        ),
    }
}

/// Display roundtrip: each `FailureDumpReportAny` variant
/// renders the same as its underlying type's own `Display`. The
/// wrapper's `Display` is transparent across all three schemas
/// (`Single`, `Dual`, `Degraded`) — confirms the dispatch arm
/// added for `Degraded` matches the same delegate-to-inner-type
/// shape as the prior two variants.
#[test]
fn report_any_display_matches_underlying() {
    let single = FailureDumpReport::default();
    let single_direct = format!("{single}");
    let single_via_any = format!("{}", FailureDumpReportAny::Single(Box::new(single)));
    assert_eq!(single_direct, single_via_any);

    let dual = DualFailureDumpReport {
        schema: SCHEMA_DUAL.to_string(),
        early: Some(FailureDumpReport::default()),
        late: FailureDumpReport::default(),
        early_max_age_jiffies: 42,
        early_threshold_jiffies: 21,
        early_skipped_reason: None,
    };
    let dual_direct = format!("{dual}");
    let dual_via_any = format!("{}", FailureDumpReportAny::Dual(Box::new(dual)));
    assert_eq!(dual_direct, dual_via_any);

    let degraded = DegradedFailureDumpReport {
        schema: SCHEMA_DEGRADED.to_string(),
        reason: format!("{REASON_DEGRADED_RENDEZVOUS_TIMEOUT} elapsed=30000ms parked=1/4"),
        vcpu_regs: vec![None, None],
        watchpoint_hit: false,
        bss_latch_state: "not_triggered".to_string(),
        exit_kind: None,
        elapsed_ms: 30_000,
    };
    let degraded_direct = format!("{degraded}");
    let degraded_via_any = format!("{}", FailureDumpReportAny::Degraded(Box::new(degraded)));
    assert_eq!(degraded_direct, degraded_via_any);
}

/// Wire-format pin: the snapshot-tag string constants used by the
/// freeze coordinator's dual-snapshot fallback emit paths must
/// remain stable across releases.
/// An operator browsing a dump directory after a regression run
/// finds files named `{stem}.snapshot.{tag}.json` and infers what
/// happened from the tag alone — silently renaming a tag breaks
/// that operator-facing convention. This test pins the literal
/// strings so a rename without intent is caught immediately rather
/// than silently changing the on-disk file naming convention.
#[test]
fn snapshot_tag_constants_pinned() {
    assert_eq!(SNAPSHOT_TAG_EARLY_DEGRADED, "early-degraded");
    assert_eq!(
        SNAPSHOT_TAG_EARLY_PRE_LATE_DEGRADED,
        "early-pre-late-degraded"
    );
    assert_eq!(
        SNAPSHOT_TAG_EARLY_ONLY_LATE_SUPPRESSED,
        "early-only-late-suppressed"
    );
    assert_eq!(
        SNAPSHOT_TAG_EARLY_ONLY_LATE_NEVER_FIRED,
        "early-only-late-never-fired"
    );
}

/// Wire-format pin: `REASON_DEGRADED_*` constant values are
/// operator-grep-stable across releases. An operator who tails a
/// degraded-dump's `reason` field or filters by the
/// `REASON_DEGRADED_*` prefix expects the string to remain byte-
/// stable; a silent rename would break operator playbooks AND any
/// external `jq '.reason | startswith("vCPU rendezvous")'` consumer.
///
/// When new `REASON_DEGRADED_*` constants land (per the doc claim at
/// the `DegradedFailureDumpReport.reason` field that "new degraded
/// causes add new `REASON_DEGRADED_*` constants rather than mutating
/// the existing ones"), extend this test to pin each new value —
/// same one-line `assert_eq!` shape.
#[test]
fn reason_degraded_constants_pinned() {
    assert_eq!(
        REASON_DEGRADED_RENDEZVOUS_TIMEOUT,
        "vCPU rendezvous timed out before parked acknowledgement"
    );
    assert_eq!(
        REASON_DEGRADED_KILL_DURING_RENDEZVOUS,
        "vCPU rendezvous aborted by external kill before parked acknowledgement"
    );
}

/// Wire-format pin: FailureDumpReport defaults to `SCHEMA_SINGLE`
/// so the freeze coordinator's early-only emit path (which
/// serialises a Captured early as the standalone single-dump when
/// the late trigger Suppresses or never fires) produces JSON the
/// consumer-side dispatcher at `FailureDumpReportAny::from_json`
/// routes to `Self::Single` rather than returning None for missing
/// schema.
///
/// Without this test a future refactor that changed the default
/// (e.g. defaulting to SCHEMA_DUAL or empty string) would silently
/// break the early-only emit path: the JSON would still serialize
/// but the consumer's discriminant-required dispatcher (added in
/// the same batch as SCHEMA_DEGRADED) would return None, and the
/// failure-dump file would appear unparseable to downstream tooling
/// even though the bytes are valid serde JSON of a valid
/// `FailureDumpReport`. The early-only emit path is fragile to
/// this drift because it doesn't override the schema field
/// explicitly — it reuses whatever the early capture wrote.
#[test]
fn early_snapshot_serializes_as_schema_single() {
    let early = FailureDumpReport::default();
    let json = serde_json::to_string(&early).expect("serialize");
    let parsed = FailureDumpReportAny::from_json(&json)
        .expect("parse: schema discriminant present and routes to Single");
    match parsed {
        FailureDumpReportAny::Single(_) => {}
        FailureDumpReportAny::Dual(_) => {
            panic!("FailureDumpReport default serialized as SCHEMA_DUAL")
        }
        FailureDumpReportAny::Degraded(_) => {
            panic!("FailureDumpReport default serialized as SCHEMA_DEGRADED")
        }
    }
}

// -- ProgRuntimeStats coverage in FailureDumpReport --

/// Roundtrip a populated `prog_runtime_stats` vector through
/// serde, including `u64::MAX` for every counter to lock in
/// the saturation contract documented on
/// [`super::bpf_prog::read_prog_runtime_stats`] (per-CPU sums use
/// `saturating_add`, so observing `u64::MAX` post-deserialize
/// proves the saturation path didn't silently wrap or truncate).
#[test]
fn prog_runtime_stats_serde_roundtrip_with_saturation() {
    let report = FailureDumpReport {
        schema: SCHEMA_SINGLE.to_string(),
        active_map_kvas: Vec::new(),
        maps: Vec::new(),
        vcpu_regs: Vec::new(),
        sdt_allocations: Vec::new(),
        sdt_alloc_unavailable: None,
        prog_runtime_stats: vec![
            super::super::bpf_prog::ProgRuntimeStats {
                name: "dispatch".to_string(),
                cnt: 12345,
                nsecs: 67890,
                misses: 3,
            },
            super::super::bpf_prog::ProgRuntimeStats {
                name: "saturated".to_string(),
                cnt: u64::MAX,
                nsecs: u64::MAX,
                misses: u64::MAX,
            },
        ],
        prog_runtime_stats_unavailable: None,
        per_cpu_time: Vec::new(),
        cgroup_psi: Vec::new(),
        per_node_numa: Vec::new(),
        per_node_numa_unavailable: None,
        task_enrichments: Vec::new(),
        task_enrichments_unavailable: None,
        event_counter_timeline: Vec::new(),
        rq_scx_states: Vec::new(),
        dsq_states: Vec::new(),
        scx_sched_state: None,
        scx_walker_unavailable: None,
        vcpu_perf_at_freeze: Vec::new(),
        dump_truncated_at_us: None,
        maps_truncated: 0,
        probe_counters: None,
        scx_static_ranges: Default::default(),
        is_placeholder: false,
        active_obj_name: None,
    };
    let json = serde_json::to_string(&report).expect("serialize");
    let parsed: FailureDumpReport = serde_json::from_str(&json).expect("deserialize");
    assert_eq!(parsed.prog_runtime_stats.len(), 2);
    assert_eq!(parsed.prog_runtime_stats[0].name, "dispatch");
    assert_eq!(parsed.prog_runtime_stats[0].cnt, 12345);
    assert_eq!(parsed.prog_runtime_stats[0].nsecs, 67890);
    assert_eq!(parsed.prog_runtime_stats[0].misses, 3);
    assert_eq!(parsed.prog_runtime_stats[1].cnt, u64::MAX);
    assert_eq!(parsed.prog_runtime_stats[1].nsecs, u64::MAX);
    assert_eq!(parsed.prog_runtime_stats[1].misses, u64::MAX);
}

/// Empty `prog_runtime_stats` skips serialization (the
/// `skip_serializing_if = "Vec::is_empty"` attribute) — same
/// pattern as the other optional vector fields. Pinning this
/// keeps the JSON tight for the common no-struct_ops-loaded
/// case.
#[test]
fn prog_runtime_stats_empty_skips_serialization() {
    let report = FailureDumpReport::default();
    let json = serde_json::to_string(&report).expect("serialize");
    assert!(
        !json.contains("prog_runtime_stats"),
        "empty prog_runtime_stats must be skipped: {json}"
    );
}

/// Display impl renders `prog_runtime_stats` under a labelled
/// section so an operator scanning failure-dump output sees
/// the per-program counters alongside the maps / vcpu_regs /
/// sdt_allocations sections. Pinning this prevents the
/// "rendered fields silently drop" regression that would mask
/// dump enrichment from reaching log readers.
#[test]
fn report_display_renders_prog_runtime_stats() {
    let report = FailureDumpReport {
        schema: SCHEMA_SINGLE.to_string(),
        active_map_kvas: Vec::new(),
        maps: Vec::new(),
        vcpu_regs: Vec::new(),
        sdt_allocations: Vec::new(),
        sdt_alloc_unavailable: None,
        prog_runtime_stats: vec![
            super::super::bpf_prog::ProgRuntimeStats {
                name: "dispatch".to_string(),
                cnt: 5,
                nsecs: 1234,
                misses: 0,
            },
            super::super::bpf_prog::ProgRuntimeStats {
                name: "enqueue".to_string(),
                cnt: 99,
                nsecs: 9999,
                misses: 7,
            },
        ],
        prog_runtime_stats_unavailable: None,
        per_cpu_time: Vec::new(),
        cgroup_psi: Vec::new(),
        per_node_numa: Vec::new(),
        per_node_numa_unavailable: None,
        task_enrichments: Vec::new(),
        task_enrichments_unavailable: None,
        event_counter_timeline: Vec::new(),
        rq_scx_states: Vec::new(),
        dsq_states: Vec::new(),
        scx_sched_state: None,
        scx_walker_unavailable: None,
        vcpu_perf_at_freeze: Vec::new(),
        dump_truncated_at_us: None,
        maps_truncated: 0,
        probe_counters: None,
        scx_static_ranges: Default::default(),
        is_placeholder: false,
        active_obj_name: None,
    };
    let out = format!("{report}");
    assert!(
        out.contains("prog_runtime_stats:"),
        "Display must render the prog_runtime_stats section: {out}"
    );
    assert!(
        out.contains("dispatch: cnt=5 nsecs=1234 misses=0"),
        "Display must render first program line: {out}"
    );
    assert!(
        out.contains("enqueue: cnt=99 nsecs=9999 misses=7"),
        "Display must render second program line: {out}"
    );
}

/// An all-empty maps/vcpu_regs/sdt_allocations report with
/// only `prog_runtime_stats` populated must still render
/// rather than fall through to the "(empty failure dump)"
/// fallback — the empty-check in the Display impl gates on
/// every optional vector, including `prog_runtime_stats`.
#[test]
fn report_display_only_prog_runtime_stats_does_not_say_empty_dump() {
    let report = FailureDumpReport {
        schema: SCHEMA_SINGLE.to_string(),
        active_map_kvas: Vec::new(),
        maps: Vec::new(),
        vcpu_regs: Vec::new(),
        sdt_allocations: Vec::new(),
        sdt_alloc_unavailable: None,
        prog_runtime_stats: vec![super::super::bpf_prog::ProgRuntimeStats {
            name: "lone".to_string(),
            cnt: 1,
            nsecs: 2,
            misses: 0,
        }],
        prog_runtime_stats_unavailable: None,
        per_cpu_time: Vec::new(),
        cgroup_psi: Vec::new(),
        per_node_numa: Vec::new(),
        per_node_numa_unavailable: None,
        task_enrichments: Vec::new(),
        task_enrichments_unavailable: None,
        event_counter_timeline: Vec::new(),
        rq_scx_states: Vec::new(),
        dsq_states: Vec::new(),
        scx_sched_state: None,
        scx_walker_unavailable: None,
        vcpu_perf_at_freeze: Vec::new(),
        dump_truncated_at_us: None,
        maps_truncated: 0,
        probe_counters: None,
        scx_static_ranges: Default::default(),
        is_placeholder: false,
        active_obj_name: None,
    };
    let out = format!("{report}");
    assert!(
        !out.contains("(empty failure dump)"),
        "Display must NOT fall through to empty fallback when \
         prog_runtime_stats is populated: {out}"
    );
    assert!(
        out.starts_with("prog_runtime_stats:"),
        "Display must lead with prog_runtime_stats section when \
         only that field is populated: {out}"
    );
}

// ---- pin failure-dump error-message strings --------------------
//
// The six REASON_* constants emitted by `dump_state` into the
// `*_unavailable` fields are wire-format markers: an operator
// parsing `.failure-dump.json` looks for these exact strings to
// distinguish "no scheduler attached" from "no walker capture
// supplied" etc. Drift in any of them silently breaks downstream
// parsing. The constants near the top of this module are the
// single source of truth; the tests below pin each constant's
// exact value so a regression that re-words a string trips both
// at the constant declaration AND at the test assertion.
//
// The companion strict-schema and chain-limit tests for
// FailureDumpReport / is_scx_allocator_type live further down in
// this module.

#[test]
fn reason_no_struct_ops_loaded_string_pinned() {
    assert_eq!(REASON_NO_STRUCT_OPS_LOADED, "no struct_ops programs loaded");
}

#[test]
fn reason_prog_accessor_unavailable_string_pinned() {
    assert_eq!(
        REASON_PROG_ACCESSOR_UNAVAILABLE,
        "prog accessor unavailable"
    );
}

#[test]
fn reason_task_walker_zero_tasks_string_pinned() {
    assert_eq!(
        REASON_TASK_WALKER_ZERO_TASKS,
        "task walker yielded zero tasks"
    );
}

#[test]
fn reason_no_task_walker_string_pinned() {
    assert_eq!(REASON_NO_TASK_WALKER, "no task walker available");
}

#[test]
fn reason_scx_walker_no_state_string_pinned() {
    assert_eq!(REASON_SCX_WALKER_NO_STATE, "scx walker reached no state");
}

#[test]
fn reason_scx_root_null_string_pinned() {
    assert_eq!(
        REASON_SCX_ROOT_NULL,
        "scx_root is NULL (no scheduler attached)"
    );
}

#[test]
fn reason_no_scx_walker_string_pinned() {
    assert_eq!(REASON_NO_SCX_WALKER, "no scx walker capture");
}

/// Every reason constant must round-trip through the JSON wire
/// format embedded in the `*_unavailable` fields. A regression
/// that altered the field's serde encoding (renamed the field,
/// added `#[serde(rename = ...)]`, etc.) would also break the
/// operator's string-match parsing — surface that here too.
#[test]
fn reason_strings_round_trip_through_serde() {
    let report = FailureDumpReport {
        prog_runtime_stats_unavailable: Some(REASON_NO_STRUCT_OPS_LOADED.to_string()),
        task_enrichments_unavailable: Some(REASON_TASK_WALKER_ZERO_TASKS.to_string()),
        scx_walker_unavailable: Some(REASON_SCX_WALKER_NO_STATE.to_string()),
        ..Default::default()
    };
    let json = serde_json::to_string(&report).expect("serialize");
    // Each reason must appear verbatim in the JSON; a future
    // wire-format change (e.g. tagged enum) would hide them
    // behind nested objects and trip this assertion.
    assert!(
        json.contains(REASON_NO_STRUCT_OPS_LOADED),
        "JSON must contain prog reason verbatim: {json}",
    );
    assert!(
        json.contains(REASON_TASK_WALKER_ZERO_TASKS),
        "JSON must contain task reason verbatim: {json}",
    );
    assert!(
        json.contains(REASON_SCX_WALKER_NO_STATE),
        "JSON must contain scx reason verbatim: {json}",
    );

    let loaded: FailureDumpReport = serde_json::from_str(&json).expect("deserialize");
    assert_eq!(
        loaded.prog_runtime_stats_unavailable.as_deref(),
        Some(REASON_NO_STRUCT_OPS_LOADED),
    );
    assert_eq!(
        loaded.task_enrichments_unavailable.as_deref(),
        Some(REASON_TASK_WALKER_ZERO_TASKS),
    );
    assert_eq!(
        loaded.scx_walker_unavailable.as_deref(),
        Some(REASON_SCX_WALKER_NO_STATE),
    );
}

// -- Strict-schema tests for FailureDumpReport -------------------
//
// Mirrors the CgroupStats / ScenarioStats / SidecarResult tests in
// assert.rs and test_support/sidecar.rs. FailureDumpReport's
// contract is narrower than CgroupStats's because most of its
// fields are intentionally optional (capture pipelines may
// legitimately produce no entries), so the CgroupStats
// "remove every field" loop would over-assert here.
//
// Asserted contract:
//   - `maps` is required on the wire (no `serde(default)`).
//   - `schema` is required on the wire (no `serde(default)`).
//     Per the dispatcher doc at [`FailureDumpReportAny::from_json`],
//     the previous "absent ⇒ single" fallback was removed; tests
//     provide `schema: "single"` explicitly to round-trip.
//   - Every other field is `serde(default, skip_serializing_if =
//     ...)` — omission MUST succeed.
//
// A regression that softens `maps` to `serde(default)` (e.g. to
// soften a schema migration) would silently produce empty-maps
// dumps that look indistinguishable from a legitimate no-maps
// run. A regression that hardens an optional field to require it
// on the wire would break replay of older dumps. Either drift
// trips this test.

/// Removing the `maps` field MUST fail deserialize. `maps`
/// carries the BPF map enumeration that is the dump's only
/// mandatory payload — every other field is
/// capture-pipeline-optional. The deserialize error MUST name
/// `maps` so a regression produces a debuggable failure rather
/// than a silent default.
#[test]
fn failure_dump_report_strict_schema_maps_required() {
    let report = FailureDumpReport::default();
    let mut full = match serde_json::to_value(&report).unwrap() {
        serde_json::Value::Object(m) => m,
        other => panic!("expected object, got {other:?}"),
    };
    assert!(
        full.remove("maps").is_some(),
        "FailureDumpReport must emit `maps` for this test to be \
         meaningful — the field has been renamed or removed",
    );
    let json = serde_json::Value::Object(full).to_string();
    let err = serde_json::from_str::<FailureDumpReport>(&json)
        .expect_err("deserialize must reject FailureDumpReport with `maps` removed");
    let msg = format!("{err}");
    assert!(
        msg.contains("maps"),
        "missing-field error for `maps` must name the field; got: {msg}",
    );
}

/// Omitting all optional fields (`vcpu_regs`, `sdt_allocations`,
/// every diagnostic Option, every capture Vec) MUST succeed and
/// produce a deserialized report whose absent fields take the
/// type's default value via `#[serde(default)]` (Vec → empty,
/// Option → None, bool → false, ScxStaticSnapshot → struct Default
/// with `ranges: []` + `skipped: 0`). `schema` is REQUIRED — it
/// has no `serde(default)`, so absent-schema JSON fails at serde
/// directly (the proximate reason this test must provide it). At
/// the wire-format level, [`FailureDumpReportAny::from_json`] also
/// rejects absent-schema JSON to avoid silently mis-routing a
/// richer wrapper as a lossy single shape. The test provides
/// `schema: "single"` explicitly and verifies the round-trip
/// preserves it.
#[test]
fn failure_dump_report_optional_fields_round_trip_when_omitted() {
    // 22-field default-shape assertions. Used twice below: once on
    // the freshly-deserialized minimal JSON, once on the
    // serialize-then-redeserialize round-trip. The double-leg
    // catches a regression where serialize drops a non-default
    // value via skip_serializing_if even though deserialize accepts
    // it — without the second leg the test name's "round_trip"
    // claim would be aspirational.
    let assert_default_shape = |r: &FailureDumpReport, leg: &str| {
        assert_eq!(
            r.schema, SCHEMA_SINGLE,
            "[{leg}] explicit `schema: single` field must round-trip; got: {:?}",
            r.schema,
        );
        // Assertions follow FailureDumpReport struct-declaration order
        // (dump/mod.rs). When a new field lands, add its default-shape
        // assert at the matching position.
        assert!(r.maps.is_empty(), "[{leg}] maps");
        assert!(r.vcpu_regs.is_empty(), "[{leg}] vcpu_regs");
        assert!(r.sdt_allocations.is_empty(), "[{leg}] sdt_allocations");
        // scx_static_ranges is a struct (ScxStaticSnapshot), not Vec/Option.
        // Pin Default::default() via ScxStaticSnapshot::is_empty() (both
        // ranges.is_empty() AND skipped == 0); see
        // monitor/scx_static_alloc.rs ScxStaticSnapshot::is_empty.
        assert!(r.scx_static_ranges.is_empty(), "[{leg}] scx_static_ranges");
        assert!(
            r.sdt_alloc_unavailable.is_none(),
            "[{leg}] sdt_alloc_unavailable"
        );
        assert!(
            r.prog_runtime_stats.is_empty(),
            "[{leg}] prog_runtime_stats"
        );
        assert!(
            r.prog_runtime_stats_unavailable.is_none(),
            "[{leg}] prog_runtime_stats_unavailable"
        );
        assert!(r.per_cpu_time.is_empty(), "[{leg}] per_cpu_time");
        assert!(r.per_node_numa.is_empty(), "[{leg}] per_node_numa");
        assert!(
            r.per_node_numa_unavailable.is_none(),
            "[{leg}] per_node_numa_unavailable"
        );
        assert!(r.task_enrichments.is_empty(), "[{leg}] task_enrichments");
        assert!(
            r.task_enrichments_unavailable.is_none(),
            "[{leg}] task_enrichments_unavailable"
        );
        assert!(
            r.event_counter_timeline.is_empty(),
            "[{leg}] event_counter_timeline"
        );
        assert!(r.rq_scx_states.is_empty(), "[{leg}] rq_scx_states");
        assert!(r.dsq_states.is_empty(), "[{leg}] dsq_states");
        assert!(r.scx_sched_state.is_none(), "[{leg}] scx_sched_state");
        assert!(
            r.scx_walker_unavailable.is_none(),
            "[{leg}] scx_walker_unavailable"
        );
        assert!(
            r.vcpu_perf_at_freeze.is_empty(),
            "[{leg}] vcpu_perf_at_freeze"
        );
        assert!(
            r.dump_truncated_at_us.is_none(),
            "[{leg}] dump_truncated_at_us"
        );
        assert!(r.probe_counters.is_none(), "[{leg}] probe_counters");
        assert!(!r.is_placeholder, "[{leg}] is_placeholder");
    };

    let minimal = serde_json::json!({ "schema": "single", "maps": [] });
    let report: FailureDumpReport = serde_json::from_value(minimal)
        .expect("deserialize must accept FailureDumpReport with `schema` + `maps`");
    assert_default_shape(&report, "first-leg");

    // Round-trip leg: serialize the default-shape report back to
    // JSON and re-deserialize. Catches a future regression where a
    // skip_serializing_if filter accidentally drops a non-default
    // value, or a serde-rename mismatches the wire-format expected
    // by from_value above. Without this leg the test name's
    // "round_trip" claim is unenforced.
    let v = serde_json::to_value(&report).expect("serialize default-shape report");
    let report2: FailureDumpReport =
        serde_json::from_value(v).expect("re-deserialize default-shape JSON");
    assert_default_shape(&report2, "second-leg");
}

// -- Pin failure-dump error-message strings ----------------------
//
// Pin the EXACT prose of error strings rendered into
// FailureDumpMap.error. Substring tests are permissive against
// drift; this regression suite asserts byte-for-byte equality so
// any re-wording during refactor surfaces in `cargo nextest run`
// before it ships.
//
// The strings are observable via FailureDumpMap.error contents
// and via downstream log scrapers (operators grep these in CI
// logs). Changing them silently breaks log tooling. Each pin
// doubles as documentation: this file shows exactly which prose
// is covered by drift detection.
//
// dump/render_map.rs producers covered here — five distinct
// render-time formats:
//   - BPF_MAP_TYPE_ARENA, no offsets
//   - BPF_MAP_TYPE_ARRAY, multi-entry
//   - BPF_MAP_TYPE_HASH, truncation
//   - BPF_MAP_TYPE_PERCPU_ARRAY, truncation
//   - unsupported map_type wildcard
//
// Each pin reproduces the production format string against a
// known-value placeholder and asserts byte equality with the
// expected literal. A drift in either the prose or the constant
// value (e.g. raising MAX_HASH_ENTRIES from 4096 to 8192) trips
// the test.
//
// The companion REASON_* constants for diagnostic Option fields
// (REASON_NO_STRUCT_OPS_LOADED, REASON_TASK_WALKER_ZERO_TASKS,
// REASON_SCX_WALKER_NO_STATE, ...) are already pinned by tests
// earlier in this module — see `report_unavailable_reasons_*`.

/// `arena BTF offsets unavailable (kernel lacks struct bpf_arena?)`
/// is rendered by the BPF_MAP_TYPE_ARENA arm when arena_offsets
/// is None — surfacing that the kernel lacks struct bpf_arena.
#[test]
fn pinned_error_arena_btf_offsets_unavailable() {
    // Assert against the PRODUCTION const the ARENA arm emits, not a
    // literal-vs-itself: a reword of render_map.rs's
    // ARENA_OFFSETS_UNAVAILABLE_MSG (which the None branch sets into
    // out.error) now trips this test.
    assert_eq!(
        super::render_map::ARENA_OFFSETS_UNAVAILABLE_MSG,
        "arena BTF offsets unavailable (kernel lacks struct bpf_arena?)",
        "arena-unavailable error string drifted from pin",
    );
}

/// `ARRAY truncated at {MAX_ARRAY_KEYS} keys (max_entries={N})` is
/// rendered by the BPF_MAP_TYPE_ARRAY arm when `max_entries` exceeds
/// the cap; `{N} keys unreadable` is appended (or stands alone) when
/// per-key reads fail. Pins the prose, the constant, and placeholder
/// ordering — mirrors `pinned_error_percpu_array_truncation`.
#[test]
fn pinned_error_array_truncation() {
    let max_entries: u32 = 9000;
    let rendered = format!("ARRAY truncated at {MAX_ARRAY_KEYS} keys (max_entries={max_entries})");
    assert_eq!(
        rendered, "ARRAY truncated at 4096 keys (max_entries=9000)",
        "ARRAY truncation string OR MAX_ARRAY_KEYS drifted from pin",
    );
    let unreadable: u32 = 3;
    assert_eq!(
        format!("{unreadable} keys unreadable"),
        "3 keys unreadable",
        "ARRAY unreadable-count string drifted from pin",
    );
}

/// `hash map truncated at {MAX_HASH_ENTRIES} entries` is
/// rendered by the BPF_MAP_TYPE_HASH arm. Pins both the prose
/// and `MAX_HASH_ENTRIES` so either drifting trips the test.
#[test]
fn pinned_error_hash_map_truncation() {
    let rendered = format!("hash map truncated at {MAX_HASH_ENTRIES} entries");
    assert_eq!(
        rendered, "hash map truncated at 4096 entries",
        "hash map truncation string OR MAX_HASH_ENTRIES drifted from pin",
    );
}

/// `PERCPU_ARRAY truncated at {MAX_PERCPU_KEYS} keys (max_entries={N})`
/// is rendered by the BPF_MAP_TYPE_PERCPU_ARRAY arm. Pins prose,
/// constant, and placeholder ordering.
#[test]
fn pinned_error_percpu_array_truncation() {
    let max_entries: u32 = 999;
    let rendered =
        format!("PERCPU_ARRAY truncated at {MAX_PERCPU_KEYS} keys (max_entries={max_entries})",);
    assert_eq!(
        rendered, "PERCPU_ARRAY truncated at 256 keys (max_entries=999)",
        "PERCPU_ARRAY truncation string OR MAX_PERCPU_KEYS drifted from pin",
    );
}

/// `unknown map_type {N}` is rendered by the wildcard arm for any
/// map_type past the kernel-uapi enum the dump renderer was built
/// against. The dispatch now enumerates every known map_type
/// (HASH/ARRAY/PERCPU_*/LRU_*/STRUCT_OPS/RINGBUF/storage/FD-array
/// families/QUEUE/STACK/BLOOM_FILTER/LPM_TRIE/INSN_ARRAY/ARENA),
/// so the wildcard only fires for kernels newer than the
/// renderer.
#[test]
fn pinned_error_unknown_map_type() {
    let other: u32 = 42;
    let rendered = format!(
        "unknown map_type {other} (kernel newer than dump renderer; \
         update render_map dispatch)"
    );
    assert_eq!(
        rendered,
        "unknown map_type 42 (kernel newer than dump renderer; update render_map dispatch)",
        "unknown-map-type string drifted from pin",
    );
}

/// Local-storage truncation diagnostic: when a TASK / INODE / SK
/// / CGRP_STORAGE map holds more than [`MAX_HASH_ENTRIES`] selems,
/// the renderer surfaces a wire-stable error so a log scraper
/// sees the cap at the same shape as the `hash map truncated`
/// diagnostic. Pin the literal in case [`MAX_HASH_ENTRIES`] or
/// the format string drift.
#[test]
fn pinned_error_local_storage_truncation() {
    let rendered = format!("local_storage map truncated at {MAX_HASH_ENTRIES} entries");
    assert_eq!(
        rendered, "local_storage map truncated at 4096 entries",
        "local_storage truncation string OR MAX_HASH_ENTRIES drifted",
    );
}

/// `fd-array dump does not walk hash-shaped FD maps (...)` is set by
/// the FD-array arm via [`super::render_map::fd_map_is_hash_shaped`]
/// when the map is SOCKHASH / DEVMAP_HASH / HASH_OF_MAPS — those store
/// slots in a hash table, not the `bpf_array.ptrs` flex array the
/// walker reads. Pins the wire-stable diagnostic so a log scraper sees
/// a stable shape; `render_map_hash_shaped_fd_map_sets_error` verifies
/// the producer actually emits it.
#[test]
fn pinned_error_fd_array_hash_shaped() {
    // Reproduce the production literal (same line continuation) so a
    // rephrasing in dump/render_map.rs trips.
    let rendered: String = "fd-array dump does not walk hash-shaped FD maps \
         (SOCKHASH/DEVMAP_HASH/HASH_OF_MAPS); populated \
         count is unavailable for these"
        .into();
    assert_eq!(
        rendered,
        "fd-array dump does not walk hash-shaped FD maps (SOCKHASH/DEVMAP_HASH/HASH_OF_MAPS); populated count is unavailable for these",
        "fd-array hash-shaped error string drifted from pin",
    );
}

// -- Per-node NUMA stats wire shape -------------------------------
//
// The live walker is a follow-up; this section pins the wire
// contract so the schema is stable before the producer lands.
//
// Asserted properties:
//   - PerNodeNumaStats serde-roundtrips with all fields preserved
//   - empty per_node_numa skips serialization (skip_serializing_if)
//   - REASON_NO_NUMA_WALKER string is exactly pinned (wire-stable)
//   - the dump_state path emits per_node_numa_unavailable until
//     the walker lands, so a downstream consumer sees the
//     diagnostic even on dumps that complete every other capture

#[test]
fn per_node_numa_stats_serde_roundtrip() {
    let s = PerNodeNumaStats {
        node: 1,
        numa_hit: 1_000_000,
        numa_miss: 100,
        numa_foreign: 50,
        numa_interleave_hit: 200,
        numa_local: 999_900,
        numa_other: 100,
    };
    let json = serde_json::to_string(&s).unwrap();
    let parsed: PerNodeNumaStats = serde_json::from_str(&json).unwrap();
    assert_eq!(parsed.node, 1);
    assert_eq!(parsed.numa_hit, 1_000_000);
    assert_eq!(parsed.numa_miss, 100);
    assert_eq!(parsed.numa_foreign, 50);
    assert_eq!(parsed.numa_interleave_hit, 200);
    assert_eq!(parsed.numa_local, 999_900);
    assert_eq!(parsed.numa_other, 100);
}

#[test]
fn per_node_numa_empty_skips_serialization() {
    let report = FailureDumpReport::default();
    let json = serde_json::to_string(&report).unwrap();
    assert!(
        !json.contains("per_node_numa"),
        "empty per_node_numa must be skipped: {json}",
    );
}

#[test]
fn per_node_numa_populated_lands_in_wire() {
    let mut report = FailureDumpReport::default();
    report.per_node_numa.push(PerNodeNumaStats {
        node: 0,
        numa_hit: 42,
        ..Default::default()
    });
    let json = serde_json::to_string(&report).unwrap();
    assert!(
        json.contains("\"per_node_numa\""),
        "populated per_node_numa must appear on wire: {json}",
    );
    assert!(
        json.contains("\"numa_hit\":42"),
        "field value must round-trip: {json}",
    );
}

#[test]
fn reason_no_numa_walker_string_pinned() {
    // Wire-stable diagnostic — operators string-match this in
    // failure-dump consumers. Drift would silently break that
    // tooling. The constant is the single source of truth; this
    // test pins it byte-for-byte.
    assert_eq!(
        REASON_NO_NUMA_WALKER,
        "no NUMA walker (host-side walker pending)"
    );
}

// -- FailureDumpPercpuHashEntry coverage --------------------------
//
// The PERCPU_HASH / LRU_PERCPU_HASH render path produces this
// shape: one entry per htab_elem with a rendered (or raw-hex)
// key plus one Vec<Option<RenderedValue>> slot per CPU. Mirror
// of FailureDumpPercpuEntry but keyed by hash key rather than
// an array index.

/// Display impl: with a rendered key and per-CPU values, the
/// output uses the indent-based `entry: key=` header with one
/// `cpu N:` line per slot.
#[test]
fn percpu_hash_entry_display_shows_key_and_cpus() {
    let entry = FailureDumpPercpuHashEntry {
        key: Some(RenderedValue::Uint { bits: 32, value: 7 }),
        key_hex: "07 00 00 00".into(),
        per_cpu: vec![
            Some(RenderedValue::Uint {
                bits: 32,
                value: 100,
            }),
            None,
            Some(RenderedValue::Uint {
                bits: 32,
                value: 300,
            }),
        ],
    };
    let out = format!("{entry}");
    assert!(out.starts_with("entry: key="), "entry header: {out}");
    assert!(out.contains("entry: key=7"), "rendered key: {out}");
    assert!(out.contains("cpu 0: 100"), "cpu 0 value: {out}");
    assert!(out.contains("cpu 1: <unmapped>"), "cpu 1 unmapped: {out}");
    assert!(out.contains("cpu 2: 300"), "cpu 2 value: {out}");
}

/// When BTF is unavailable the rendered key is `None`; Display
/// falls back to the raw hex representation with a `(raw)` tag,
/// mirroring FailureDumpEntry.
#[test]
fn percpu_hash_entry_display_falls_back_to_hex_when_no_btf() {
    let entry = FailureDumpPercpuHashEntry {
        key: None,
        key_hex: "ab cd ef 01".into(),
        per_cpu: vec![Some(RenderedValue::Uint { bits: 32, value: 1 })],
    };
    let out = format!("{entry}");
    assert!(
        out.contains("ab cd ef 01 (raw)"),
        "raw hex with (raw) marker: {out}",
    );
}

/// Empty per-CPU slot list — every CPU `<unmapped>`. The shape
/// stays well-formed (header + body or just unmapped markers)
/// rather than panicking.
#[test]
fn percpu_hash_entry_display_all_unmapped_cpus() {
    let entry = FailureDumpPercpuHashEntry {
        key: Some(RenderedValue::Uint { bits: 32, value: 0 }),
        key_hex: "00 00 00 00".into(),
        per_cpu: vec![None, None, None],
    };
    let out = format!("{entry}");
    assert!(out.contains("cpu 0: <unmapped>"));
    assert!(out.contains("cpu 1: <unmapped>"));
    assert!(out.contains("cpu 2: <unmapped>"));
}

/// Empty per_cpu vec — entry body has no `cpu N:` lines but the
/// `entry: key=` header is still emitted.
#[test]
fn percpu_hash_entry_display_empty_per_cpu() {
    let entry = FailureDumpPercpuHashEntry {
        key: Some(RenderedValue::Uint { bits: 32, value: 0 }),
        key_hex: "00 00 00 00".into(),
        per_cpu: vec![],
    };
    let out = format!("{entry}");
    assert!(out.starts_with("entry: key="), "header: {out}");
    assert!(!out.contains("cpu "), "no cpu lines: {out}");
}

/// Serde roundtrip: every field preserved on encode/decode.
/// Pin the wire shape so a future serde-attribute change
/// (rename, skip_serializing_if) trips the test.
#[test]
fn percpu_hash_entry_serde_roundtrip() {
    let entry = FailureDumpPercpuHashEntry {
        key: Some(RenderedValue::Uint {
            bits: 32,
            value: 42,
        }),
        key_hex: "2a 00 00 00".into(),
        per_cpu: vec![
            Some(RenderedValue::Uint {
                bits: 32,
                value: 100,
            }),
            None,
        ],
    };
    let json = serde_json::to_string(&entry).expect("serialize");
    let parsed: FailureDumpPercpuHashEntry = serde_json::from_str(&json).expect("deserialize");
    assert!(parsed.key.is_some());
    assert_eq!(parsed.key_hex, "2a 00 00 00");
    assert_eq!(parsed.per_cpu.len(), 2);
    assert!(parsed.per_cpu[0].is_some());
    assert!(parsed.per_cpu[1].is_none());
}

/// `key` is skip_serializing_if=Option::is_none — when absent on
/// the wire it must omit, then deserialize back as None.
#[test]
fn percpu_hash_entry_key_skips_when_none() {
    let entry = FailureDumpPercpuHashEntry {
        key: None,
        key_hex: "00".into(),
        per_cpu: vec![],
    };
    let json = serde_json::to_string(&entry).unwrap();
    assert!(
        !json.contains("\"key\":"),
        "None key must skip on wire: {json}",
    );
}

// -- FailureDumpMap with percpu_hash_entries Display --------------

/// A FailureDumpMap of type PERCPU_HASH renders the percpu_hash
/// entries below the header — matches the existing percpu_entries
/// pattern.
#[test]
fn map_display_percpu_hash_entries_render() {
    let m = FailureDumpMap {
        name: "percpu_hash".into(),
        map_kva: 0,
        map_type: BPF_MAP_TYPE_PERCPU_HASH,
        value_size: 4,
        max_entries: 100,
        value: None,
        entries: Vec::new(),
        array_entries: Vec::new(),
        percpu_entries: Vec::new(),
        percpu_hash_entries: vec![FailureDumpPercpuHashEntry {
            key: Some(RenderedValue::Uint { bits: 32, value: 1 }),
            key_hex: "01 00 00 00".into(),
            per_cpu: vec![
                Some(RenderedValue::Uint {
                    bits: 32,
                    value: 10,
                }),
                Some(RenderedValue::Uint {
                    bits: 32,
                    value: 20,
                }),
            ],
        }],
        arena: None,
        ringbuf: None,
        stack_trace: None,
        fd_array: None,
        error: None,
    };
    let out = format!("{m}");
    assert!(out.contains("map percpu_hash (type="), "header: {out}");
    assert!(out.contains("entry: key=1"), "key surfaces: {out}");
    assert!(out.contains("cpu 0: 10"), "cpu 0: {out}");
    assert!(out.contains("cpu 1: 20"), "cpu 1: {out}");
}

// -- New pinned error strings on render_map arms ------------------
//
// Each new arm in the render_map dispatch produces a wire-stable
// error string. Pin every one byte-for-byte so a re-wording
// refactor surfaces in cargo nextest before it reaches a log
// scraper.

#[test]
fn pinned_error_percpu_hash_truncation() {
    let rendered = format!("percpu hash map truncated at {MAX_HASH_ENTRIES} entries");
    assert_eq!(
        rendered, "percpu hash map truncated at 4096 entries",
        "percpu hash truncation string OR MAX_HASH_ENTRIES drifted",
    );
}

/// STRUCT_OPS produces TWO distinct error strings since the
/// offsets-vs-region split landed: one when struct_ops_offsets
/// are absent, one when the value region is unmapped.
#[test]
fn pinned_error_struct_ops_offsets_unresolved() {
    let expected = "STRUCT_OPS value unreadable: bpf_struct_ops_map BTF offsets unresolved \
         (kernel without struct_ops support, or vmlinux BTF stripped of \
         bpf_struct_ops_map / bpf_struct_ops_value).";
    let rendered: String = expected.into();
    assert_eq!(
        rendered, expected,
        "STRUCT_OPS offsets-unresolved error string drifted",
    );
}

#[test]
fn pinned_error_struct_ops_region_unmapped() {
    let expected = "STRUCT_OPS value unreadable: value region unmapped. Live-host \
         backend reads via BPF_MAP_LOOKUP_ELEM at key=0.";
    let rendered: String = expected.into();
    assert_eq!(
        rendered, expected,
        "STRUCT_OPS region-unmapped error string drifted",
    );
}

#[test]
fn pinned_error_cgroup_storage_deprecated() {
    // Assert against the PRODUCTION table, not a literal-vs-itself: a
    // reword of the render_map.rs entry must trip this.
    let (_, msg) = MAP_TYPE_EXPLANATIONS
        .iter()
        .find(|(t, _)| *t == BPF_MAP_TYPE_CGROUP_STORAGE)
        .expect("CGROUP_STORAGE entry present");
    assert_eq!(
        *msg,
        "deprecated cgroup-attached storage; use CGRP_STORAGE on newer kernels",
    );
}

#[test]
fn pinned_error_queue_stack_destructive() {
    let expected = "QUEUE/STACK are destructive (peek shows only the head; pop consumes); \
         no enumeration API";
    // Both QUEUE and STACK map to this entry; assert the production
    // table carries it byte-exact under both discriminants.
    for ty in [BPF_MAP_TYPE_QUEUE, BPF_MAP_TYPE_STACK] {
        let (_, msg) = MAP_TYPE_EXPLANATIONS
            .iter()
            .find(|(t, _)| *t == ty)
            .expect("QUEUE/STACK entry present");
        assert_eq!(*msg, expected);
    }
}

#[test]
fn pinned_error_bloom_filter() {
    let (_, msg) = MAP_TYPE_EXPLANATIONS
        .iter()
        .find(|(t, _)| *t == BPF_MAP_TYPE_BLOOM_FILTER)
        .expect("BLOOM_FILTER entry present");
    assert_eq!(
        *msg,
        "BLOOM_FILTER is a probabilistic set; no key enumeration is possible",
    );
}

#[test]
fn pinned_error_lpm_trie() {
    let expected = "LPM_TRIE walker not implemented (keyed by prefixlen + data); \
         use bpf(2) BPF_MAP_GET_NEXT_KEY for live-host iteration";
    let (_, msg) = MAP_TYPE_EXPLANATIONS
        .iter()
        .find(|(t, _)| *t == BPF_MAP_TYPE_LPM_TRIE)
        .expect("LPM_TRIE entry present");
    assert_eq!(*msg, expected);
}

#[test]
fn pinned_error_unknown_map_type_format() {
    // The wildcard arm format string carries a placeholder.
    let other: u32 = 99;
    let rendered = format!(
        "unknown map_type {other} (kernel newer than dump renderer; \
         update render_map dispatch)"
    );
    assert_eq!(
        rendered,
        "unknown map_type 99 (kernel newer than dump renderer; \
         update render_map dispatch)",
    );
}

// -- FailureDumpMap.percpu_hash_entries field round-trip ----------

/// `percpu_hash_entries` is `skip_serializing_if = Vec::is_empty`
/// — empty must be omitted; populated must round-trip.
#[test]
fn map_percpu_hash_entries_skips_when_empty() {
    let m = FailureDumpMap {
        name: "test".into(),
        map_kva: 0,
        map_type: BPF_MAP_TYPE_HASH,
        value_size: 4,
        max_entries: 1,
        value: None,
        entries: Vec::new(),
        array_entries: Vec::new(),
        percpu_entries: Vec::new(),
        percpu_hash_entries: Vec::new(),
        arena: None,
        ringbuf: None,
        stack_trace: None,
        fd_array: None,
        error: None,
    };
    let json = serde_json::to_string(&m).unwrap();
    assert!(
        !json.contains("percpu_hash_entries"),
        "empty must skip: {json}",
    );
}

#[test]
fn map_percpu_hash_entries_round_trip_when_populated() {
    let m = FailureDumpMap {
        name: "ph".into(),
        map_kva: 0,
        map_type: BPF_MAP_TYPE_PERCPU_HASH,
        value_size: 4,
        max_entries: 1,
        value: None,
        entries: Vec::new(),
        array_entries: Vec::new(),
        percpu_entries: Vec::new(),
        percpu_hash_entries: vec![FailureDumpPercpuHashEntry {
            key: Some(RenderedValue::Uint { bits: 32, value: 1 }),
            key_hex: "01 00 00 00".into(),
            per_cpu: vec![Some(RenderedValue::Uint {
                bits: 32,
                value: 99,
            })],
        }],
        arena: None,
        ringbuf: None,
        stack_trace: None,
        fd_array: None,
        error: None,
    };
    let json = serde_json::to_string(&m).expect("serialize");
    assert!(
        json.contains("percpu_hash_entries"),
        "populated must serialize: {json}",
    );
    let parsed: FailureDumpMap = serde_json::from_str(&json).expect("deserialize");
    assert_eq!(parsed.percpu_hash_entries.len(), 1);
    assert_eq!(parsed.percpu_hash_entries[0].key_hex, "01 00 00 00");
    assert_eq!(parsed.percpu_hash_entries[0].per_cpu.len(), 1);
}

// -- AccessorMemReader-shape arena pointer chasing ---------------
//
// The MemReader trait is implemented by AccessorMemReader. The
// `is_arena_addr` and `read_arena` methods don't need a guest
// kernel — they only consult the arena_snapshot field
// (specifically `snap.user_vm_start` and `snap.pages`). Test
// those paths via stand-in types that mirror production logic
// line-for-line.

/// `is_arena_addr` returns false when the snapshot is None
/// (no arena attached). Mirrors the no-arena fast path.
#[test]
fn accessor_mem_reader_no_snapshot_rejects_all_addrs() {
    struct StubReader<'a> {
        arena_snapshot: Option<&'a super::super::arena::ArenaSnapshot>,
    }
    impl super::super::btf_render::MemReader for StubReader<'_> {
        fn read_kva(&self, _: u64, _: usize) -> Option<Vec<u8>> {
            None
        }
        fn is_arena_addr(&self, addr: u64) -> bool {
            let Some(snap) = self.arena_snapshot else {
                return false;
            };
            if snap.user_vm_start == 0 {
                return false;
            }
            addr >= snap.user_vm_start && addr < snap.user_vm_start.wrapping_add(1 << 32)
        }
    }
    let r = StubReader {
        arena_snapshot: None,
    };
    assert!(!r.is_arena_addr(0));
    assert!(!r.is_arena_addr(0x10000));
    assert!(!r.is_arena_addr(u64::MAX));
}

/// `is_arena_addr` returns false when the snapshot is present
/// but `user_vm_start == 0` (the snapshot bailed before reading
/// the user_vm_start anchor).
#[test]
fn accessor_mem_reader_zero_user_vm_start_rejects_all() {
    use super::super::arena::ArenaSnapshot;
    use super::render_map::is_arena_addr_in_snapshot;
    // Drive the REAL production gate (is_arena_addr forwards to this), not
    // a stub copy: a snapshot present but user_vm_start == 0 (the pre-pass
    // bailed before reading the anchor) must reject EVERY address. This is
    // the discriminating case for the `if snap.user_vm_start == 0 { return
    // false }` guard — delete it and a zero-anchor snapshot would accept
    // any addr < (1<<32). The 0xFFFF_FFFF case below would then wrongly
    // pass.
    let zero = ArenaSnapshot {
        user_vm_start: 0,
        ..ArenaSnapshot::default()
    };
    assert!(!is_arena_addr_in_snapshot(Some(&zero), 0));
    assert!(!is_arena_addr_in_snapshot(Some(&zero), 0x100000));
    assert!(!is_arena_addr_in_snapshot(Some(&zero), 0xFFFF_FFFF));
    assert!(!is_arena_addr_in_snapshot(None, 0x1000));
}

/// `is_arena_addr` enforces the `[user_vm_start, user_vm_start +
/// 4 GiB)` half-open range reflecting the kernel's SZ_4G
/// enforcement in arena_map_alloc.
#[test]
fn accessor_mem_reader_arena_addr_range_via_snapshot() {
    use super::super::arena::ArenaSnapshot;
    let snap = ArenaSnapshot {
        user_vm_start: 0x10_0000_0000,
        ..ArenaSnapshot::default()
    };
    struct StubReader<'a> {
        arena_snapshot: Option<&'a ArenaSnapshot>,
    }
    impl super::super::btf_render::MemReader for StubReader<'_> {
        fn read_kva(&self, _: u64, _: usize) -> Option<Vec<u8>> {
            None
        }
        fn is_arena_addr(&self, addr: u64) -> bool {
            let Some(snap) = self.arena_snapshot else {
                return false;
            };
            if snap.user_vm_start == 0 {
                return false;
            }
            addr >= snap.user_vm_start && addr < snap.user_vm_start.wrapping_add(1 << 32)
        }
    }
    let r = StubReader {
        arena_snapshot: Some(&snap),
    };
    // Below start: rejected.
    assert!(!r.is_arena_addr(0));
    assert!(!r.is_arena_addr(0xf_ffff_ffff));
    // At start: accepted.
    assert!(r.is_arena_addr(0x10_0000_0000));
    // Just below upper bound: accepted.
    assert!(r.is_arena_addr(0x10_0000_0000 + (1 << 32) - 1));
    // At upper bound: rejected (exclusive).
    assert!(!r.is_arena_addr(0x10_0000_0000 + (1 << 32)));
}

/// `read_arena` returns None when no snapshot is attached.
#[test]
fn accessor_mem_reader_read_arena_none_when_no_snapshot() {
    use super::render_map::read_arena_in_snapshot;
    use std::collections::HashMap;
    // Drive the REAL production helper (the body of
    // AccessorMemReader::read_arena), not a divergent stub: no snapshot
    // short-circuits at `?` before read_kva.
    assert!(
        read_arena_in_snapshot(None, &HashMap::new(), 0x1234, 8, |_, _| {
            panic!("read_kva must not run when there is no snapshot")
        })
        .is_none()
    );
}

/// `read_arena` page-aligns the address and returns the matching
/// page's bytes when the full request fits in one page. Mirrors the
/// production `read_arena` logic line-for-line so a regression in
/// either trips the test. Cross-page reads (where `offset + len`
/// exceeds the page) return None per the documented MemReader
/// contract — partial bytes are not handed back.
#[test]
fn accessor_mem_reader_read_arena_page_hit() {
    use super::super::arena::{ArenaPage, ArenaSnapshot};
    use super::render_map::read_arena_in_snapshot;
    use std::collections::HashMap;
    let snap = ArenaSnapshot {
        pages: vec![ArenaPage {
            user_addr: 0x1000,
            bytes: (0..=0xffu8).cycle().take(4096).collect(),
        }],
        ..ArenaSnapshot::default()
    };
    // Production resolves the page via the arena_page_index HashMap fast
    // path, not a linear scan — build the index the real reader uses.
    let mut index: HashMap<u64, usize> = HashMap::new();
    index.insert(0x1000, 0);

    // Page base: bytes 0..8 of (0..=0xff cycled), via the index. read_kva
    // must NOT run on the fast path.
    assert_eq!(
        read_arena_in_snapshot(Some(&snap), &index, 0x1000, 8, |_, _| panic!(
            "fast path must not call read_kva"
        ))
        .unwrap(),
        vec![0, 1, 2, 3, 4, 5, 6, 7],
    );
    // Offset 100: bytes 100..108.
    let at100 = read_arena_in_snapshot(Some(&snap), &index, 0x1000 + 100, 8, |_, _| None)
        .expect("offset hit");
    assert_eq!(at100[0], 100);

    // Cross-page: offset 4090 + len 100 = 4190 > 4096 → None (production
    // `> 4096` bound, not the stub's `> bytes.len()`).
    assert!(
        read_arena_in_snapshot(Some(&snap), &index, 0x1000 + 4090, 100, |_, _| None).is_none(),
        "cross-page read must return None",
    );
    // Hostile overflow: checked_add must reject rather than wrap.
    assert!(read_arena_in_snapshot(Some(&snap), &index, u64::MAX, 8, |_, _| None).is_none());

    // Short captured page (bytes shorter than the requested end) with
    // kern_vm_start == 0 falls through to the slow path and returns None
    // — the old stub got this backwards (returned None early instead of
    // falling through).
    let short = ArenaSnapshot {
        pages: vec![ArenaPage {
            user_addr: 0x5000,
            bytes: vec![0u8; 2048],
        }],
        ..ArenaSnapshot::default()
    };
    let mut short_index: HashMap<u64, usize> = HashMap::new();
    short_index.insert(0x5000, 0);
    assert!(
        read_arena_in_snapshot(Some(&short), &short_index, 0x5000, 4096, |_, _| None).is_none(),
        "short captured page must fall through, not return a short slice",
    );
}

/// `read_arena` returns None when the address falls outside the
/// captured pages (page miss).
#[test]
fn accessor_mem_reader_read_arena_page_miss_returns_none() {
    use super::super::arena::ArenaSnapshot;
    use super::render_map::read_arena_in_snapshot;
    use std::cell::Cell;
    use std::collections::HashMap;

    // Page miss + kern_vm_start == 0 → no anchor for the slow path → None
    // (read_kva must not even run).
    let no_anchor = ArenaSnapshot {
        kern_vm_start: 0,
        ..ArenaSnapshot::default()
    };
    assert!(
        read_arena_in_snapshot(Some(&no_anchor), &HashMap::new(), 0x2000, 8, |_, _| panic!(
            "zero kern_vm_start must short-circuit before read_kva"
        ))
        .is_none(),
    );

    // Page miss + non-zero kern_vm_start → compose
    // kern_vm_start.wrapping_add(addr & 0xFFFF_FFFF) and route to read_kva
    // with the same len. This is the only assertion that pins the
    // 0xFFFF_FFFF mask + wrapping_add formula + len passthrough.
    let anchor: u64 = 0xffff_8000_0000_0000;
    let snap = ArenaSnapshot {
        kern_vm_start: anchor,
        ..ArenaSnapshot::default()
    };
    let addr: u64 = 0x1_2345_6010;
    let seen: Cell<Option<(u64, usize)>> = Cell::new(None);
    let got = read_arena_in_snapshot(Some(&snap), &HashMap::new(), addr, 8, |kva, l| {
        seen.set(Some((kva, l)));
        Some(vec![0xAB; l])
    })
    .expect("slow path returns the read_kva bytes");
    assert_eq!(got, vec![0xAB; 8]);
    let (kva, l) = seen.get().expect("read_kva must run on the slow path");
    assert_eq!(l, 8);
    assert_eq!(kva, anchor.wrapping_add(addr & 0xFFFF_FFFF));
}

/// MemReader default impls: any reader that doesn't override
/// `is_arena_addr` and `read_arena` gets the default `false` /
/// `None` behavior.
#[test]
fn mem_reader_default_impls_skip_arena() {
    struct MinReader;
    impl super::super::btf_render::MemReader for MinReader {
        fn read_kva(&self, _: u64, _: usize) -> Option<Vec<u8>> {
            None
        }
    }
    let r = MinReader;
    assert!(!r.is_arena_addr(0));
    assert!(!r.is_arena_addr(u64::MAX));
    assert!(r.read_arena(0x1234, 8).is_none());
}

// -- AccessorMemReader cast_lookup --------------------------------
//
// `AccessorMemReader::cast_lookup` (render_map.rs) consults its
// `cast_map` field: `Some(map)` returns
// `map.get(&(parent_type_id, member_byte_offset)).copied()`,
// `None` returns `None`. `AccessorMemReader` itself is private to
// render_map.rs, but the body lives in the free helper
// `cast_lookup_in_map` (the same extraction convention as
// `is_arena_addr_in_snapshot` / `read_arena_in_snapshot` /
// `resolve_arena_type_in_index`). The production `cast_lookup`
// forwards to it, so the tests below call the helper directly and
// therefore exercise the real lookup body — not a re-declared copy.

/// `cast_lookup_in_map` (the body of `AccessorMemReader::cast_lookup`)
/// with a populated [`CastMap`] returns the matching [`CastHit`] when
/// the `(parent_type_id, member_byte_offset)` key is present, and
/// `None` when it is not. Drives the production helper directly so a
/// regression in the key tuple order or the present-key semantics
/// trips this test.
#[test]
fn accessor_mem_reader_cast_lookup_with_populated_map() {
    use super::super::cast_analysis::{AddrSpace, CastHit, CastMap};
    use super::render_map::cast_lookup_in_map;

    // Build a CastMap with two entries: one Arena, one Kernel.
    // Parent ids and member offsets are arbitrary u32s — the
    // map's role is opaque key/value storage and the lookup is
    // a plain BTreeMap::get.
    let mut map = CastMap::new();
    map.insert(
        (42, 8),
        CastHit {
            alloc_size: None,
            target_type_id: 99,
            addr_space: AddrSpace::Arena,
        },
    );
    map.insert(
        (42, 16),
        CastHit {
            alloc_size: None,
            target_type_id: 100,
            addr_space: AddrSpace::Kernel,
        },
    );

    // Hit on (42, 8): returns the Arena CastHit.
    let hit_arena = cast_lookup_in_map(Some(&map), 42, 8)
        .expect("populated map must return CastHit for present key");
    assert_eq!(
        hit_arena.target_type_id, 99,
        "target_type_id must match the inserted value",
    );
    assert!(
        matches!(hit_arena.addr_space, AddrSpace::Arena),
        "addr_space hint must round-trip through cast_lookup",
    );

    // Hit on (42, 16): returns the Kernel CastHit.
    let hit_kernel = cast_lookup_in_map(Some(&map), 42, 16)
        .expect("populated map must return CastHit for present key");
    assert_eq!(hit_kernel.target_type_id, 100);
    assert!(
        matches!(hit_kernel.addr_space, AddrSpace::Kernel),
        "second entry's addr_space must round-trip distinctly from the first",
    );

    // Miss on a non-present key: returns None.
    assert!(
        cast_lookup_in_map(Some(&map), 42, 24).is_none(),
        "key not in map must produce None (no fallback to nearby offsets)",
    );
    // The key tuple is `(parent_type_id, member_byte_offset)` in
    // THAT order: swapping a present (parent, offset) pair (42, 8)
    // to (8, 42) must miss, pinning that the helper keys on the
    // tuple in the documented order rather than either projection.
    assert!(
        cast_lookup_in_map(Some(&map), 8, 42).is_none(),
        "swapped (offset, parent) key must miss — tuple order is (parent, offset)",
    );
    assert!(
        cast_lookup_in_map(Some(&map), 99, 8).is_none(),
        "different parent_type_id must produce None even with same offset",
    );
}

/// `cast_lookup_in_map` with `cast_map = None` returns `None` for
/// every query. The `?` operator on the Option short-circuits before
/// the BTreeMap lookup. Production code path: when the dump pass runs
/// without a cast analysis (no scheduler binary supplied), every
/// `u64` field renders as a plain counter — no typed-pointer
/// promotion fires. Drives the production helper directly so the
/// None-short-circuit guards production, not a copy.
#[test]
fn accessor_mem_reader_cast_lookup_with_none_map() {
    use super::render_map::cast_lookup_in_map;

    // Every query returns None — the `?` on the None map fires
    // before any map lookup happens.
    assert!(cast_lookup_in_map(None, 0, 0).is_none());
    assert!(cast_lookup_in_map(None, 42, 8).is_none());
    assert!(cast_lookup_in_map(None, u32::MAX, u32::MAX).is_none());
}

// -- AccessorMemReader resolve_arena_type -------------------------
//
// `AccessorMemReader::resolve_arena_type` (render_map.rs) gates on
// `is_arena_addr` (snapshot's [user_vm_start, user_vm_start + 4 GiB)),
// masks the chased address with `0xFFFF_FFFF`, then runs a range
// lookup against the per-pass [`ArenaSlotIndex`] keyed on
// slot-start to find the slot containing the address.
// `AccessorMemReader` itself is private to render_map.rs, but the
// gate / range / dispatch logic lives in the free helper
// `resolve_arena_type_in_index`. The tests below use a stand-in
// `ResolveArenaTypeStub` whose `resolve_arena_type` impl delegates
// directly to that helper — so the tests exercise the production
// path without duplicating its body.
//
// Hit shape: `Some(ArenaResolveHit { target_type_id, header_skip })`.
// `header_skip == header_size` for slot-start chases, `0` for
// payload-start chases, and the entry returns `None` for any
// other in-slot offset (or out-of-range address).

/// Stand-in for `AccessorMemReader::resolve_arena_type` shared by
/// every test in this section. The trait method delegates to the
/// free helper [`super::render_map::resolve_arena_type_in_index`]
/// — the gate/range/dispatch logic the production
/// `AccessorMemReader::resolve_arena_type` reaches via the
/// outer [`super::render_map::resolve_arena_type_with_static_fallback`]
/// wrapper. With no scx_static index seeded the wrapper degrades
/// to exactly [`super::render_map::resolve_arena_type_in_index`]'s
/// behaviour, so this stub still exercises the production
/// per-instance allocator path byte for byte. Dedicated tests in
/// the `resolve_arena_type_with_static_fallback` section below
/// cover the wrapper's scx_static fall-through.
///
/// The stub itself only carries the two borrows the helper needs
/// (`arena_snapshot`, `arena_slot_index`); `is_arena_addr` is also
/// implemented for parity with the production reader's surface,
/// though the tests below only assert against `resolve_arena_type`.
struct ResolveArenaTypeStub<'a> {
    arena_snapshot: Option<&'a super::super::arena::ArenaSnapshot>,
    arena_slot_index: Option<&'a super::render_map::ArenaSlotIndex>,
}

impl super::super::btf_render::MemReader for ResolveArenaTypeStub<'_> {
    fn read_kva(&self, _: u64, _: usize) -> Option<Vec<u8>> {
        None
    }
    fn is_arena_addr(&self, addr: u64) -> bool {
        super::render_map::is_arena_addr_in_snapshot(self.arena_snapshot, addr)
    }
    fn resolve_arena_type(&self, addr: u64) -> Option<super::super::btf_render::ArenaResolveHit> {
        super::render_map::resolve_arena_type_in_index(
            self.arena_snapshot,
            self.arena_slot_index,
            addr,
            0,
        )
    }
}

/// Slot-start chase: a chased address that equals the slot's start
/// resolves with `header_skip = header_size`. The renderer reads
/// `header_skip + btf_size` bytes from the chased address and slices
/// off the header before rendering the payload — covers the
/// `scx_task_map_val.data` shape that prompted the range-based
/// rewrite.
#[test]
fn accessor_mem_reader_resolve_arena_type_slot_start_returns_header_skip() {
    use super::super::arena::ArenaSnapshot;
    use super::render_map::{ArenaSlotIndex, ArenaSlotInfo};

    let snap = ArenaSnapshot {
        user_vm_start: 0x10_0000_0000,
        ..ArenaSnapshot::default()
    };
    let mut index = ArenaSlotIndex::new();
    index.insert(
        0x0000_1000,
        ArenaSlotInfo {
            elem_size: 24, // 8-byte header + 16-byte payload
            header_size: 8,
            target_type_id: 7,
            source_btf_kva: 0,
        },
    );

    let r = ResolveArenaTypeStub {
        arena_snapshot: Some(&snap),
        arena_slot_index: Some(&index),
    };

    // Slot-start chase: full 64-bit address whose low 32 bits
    // match the slot start. Returns the payload type id paired
    // with `header_skip = 8`.
    assert_eq!(
        r.resolve_arena_type(0x10_0000_1000),
        Some(super::super::btf_render::ArenaResolveHit {
            target_type_id: 7,
            header_skip: 8,
        }),
        "slot-start address must resolve with header_skip = header_size",
    );
}

/// Payload-start chase: a chased address that lands at
/// `slot_start + header_size` resolves with `header_skip = 0`.
/// The renderer reads `btf_size` bytes from the chased address
/// directly — the historical case (`scx_task_data(p)` return cached
/// in `cached_taskc_raw`) keeps working under the new range index.
#[test]
fn accessor_mem_reader_resolve_arena_type_payload_start_returns_zero_skip() {
    use super::super::arena::ArenaSnapshot;
    use super::render_map::{ArenaSlotIndex, ArenaSlotInfo};

    let snap = ArenaSnapshot {
        user_vm_start: 0x10_0000_0000,
        ..ArenaSnapshot::default()
    };
    let mut index = ArenaSlotIndex::new();
    index.insert(
        0x0000_1000,
        ArenaSlotInfo {
            elem_size: 24,
            header_size: 8,
            target_type_id: 7,
            source_btf_kva: 0,
        },
    );

    let r = ResolveArenaTypeStub {
        arena_snapshot: Some(&snap),
        arena_slot_index: Some(&index),
    };

    // Payload-start chase: address = slot_start + header_size.
    assert_eq!(
        r.resolve_arena_type(0x10_0000_1008),
        Some(super::super::btf_render::ArenaResolveHit {
            target_type_id: 7,
            header_skip: 0,
        }),
        "payload-start address must resolve with header_skip = 0",
    );
}

/// Mid-header / mid-payload addresses fall inside the slot range but
/// at offsets the bridge cannot route into a payload render.
/// Pinning the None return so a future "render mid-struct" extension
/// is a deliberate change of behaviour, not an accidental fall-through.
#[test]
fn accessor_mem_reader_resolve_arena_type_interior_returns_none() {
    use super::super::arena::ArenaSnapshot;
    use super::render_map::{ArenaSlotIndex, ArenaSlotInfo};

    let snap = ArenaSnapshot {
        user_vm_start: 0x10_0000_0000,
        ..ArenaSnapshot::default()
    };
    let mut index = ArenaSlotIndex::new();
    index.insert(
        0x0000_1000,
        ArenaSlotInfo {
            elem_size: 24,
            header_size: 8,
            target_type_id: 7,
            source_btf_kva: 0,
        },
    );

    let r = ResolveArenaTypeStub {
        arena_snapshot: Some(&snap),
        arena_slot_index: Some(&index),
    };

    // Mid-header (offset 4 < header_size 8): no payload render.
    assert!(
        r.resolve_arena_type(0x10_0000_1004).is_none(),
        "mid-header offset must not resolve",
    );
    // Mid-payload (offset 12, header_size 8 → payload offset 4):
    // bridge does not render mid-struct today.
    assert!(
        r.resolve_arena_type(0x10_0000_100C).is_none(),
        "mid-payload offset must not resolve",
    );
}

/// Range search across multiple seeded slots picks the slot whose
/// `[slot_start, slot_start + elem_size)` range contains the
/// chased address. Pins the `BTreeMap::range(..=key).next_back()`
/// step against a regression that might fall through to an
/// unrelated entry whose key collides on the low 32 bits.
#[test]
fn accessor_mem_reader_resolve_arena_type_range_picks_correct_slot() {
    use super::super::arena::ArenaSnapshot;
    use super::render_map::{ArenaSlotIndex, ArenaSlotInfo};

    let snap = ArenaSnapshot {
        user_vm_start: 0x10_0000_0000,
        ..ArenaSnapshot::default()
    };
    let mut index = ArenaSlotIndex::new();
    // Two non-overlapping slots, distinct payload type ids.
    index.insert(
        0x0000_1000,
        ArenaSlotInfo {
            elem_size: 16,
            header_size: 8,
            target_type_id: 7,
            source_btf_kva: 0,
        },
    );
    index.insert(
        0x0000_2000,
        ArenaSlotInfo {
            elem_size: 16,
            header_size: 8,
            target_type_id: 11,
            source_btf_kva: 0,
        },
    );

    let r = ResolveArenaTypeStub {
        arena_snapshot: Some(&snap),
        arena_slot_index: Some(&index),
    };

    use super::super::btf_render::ArenaResolveHit;

    // First slot — slot-start of slot 0x1000.
    assert_eq!(
        r.resolve_arena_type(0x10_0000_1000),
        Some(ArenaResolveHit {
            target_type_id: 7,
            header_skip: 8,
        }),
    );
    // First slot — payload-start of slot 0x1000.
    assert_eq!(
        r.resolve_arena_type(0x10_0000_1008),
        Some(ArenaResolveHit {
            target_type_id: 7,
            header_skip: 0,
        }),
    );
    // Second slot — slot-start of slot 0x2000.
    assert_eq!(
        r.resolve_arena_type(0x10_0000_2000),
        Some(ArenaResolveHit {
            target_type_id: 11,
            header_skip: 8,
        }),
    );
    // Second slot — payload-start of slot 0x2000.
    assert_eq!(
        r.resolve_arena_type(0x10_0000_2008),
        Some(ArenaResolveHit {
            target_type_id: 11,
            header_skip: 0,
        }),
    );
    // Address between the two slots (past slot 1's end, before
    // slot 2's start): no slot contains it.
    assert!(
        r.resolve_arena_type(0x10_0000_1800).is_none(),
        "address between known slots must not resolve",
    );
    // Address past every seeded slot's end.
    assert!(
        r.resolve_arena_type(0x10_0000_3000).is_none(),
        "address past every known slot must not resolve",
    );
}

/// `resolve_arena_type` returns `None` for an address that lies
/// OUTSIDE the arena window, even when the index has a seeded entry
/// whose low-32 range covers the address's low 32 bits. The
/// `is_arena_addr` gate fires first; without it a stale index entry
/// could surface for any 64-bit value whose low 32 bits land in a
/// captured slot's range by happenstance.
#[test]
fn accessor_mem_reader_resolve_arena_type_rejects_out_of_window() {
    use super::super::arena::ArenaSnapshot;
    use super::render_map::{ArenaSlotIndex, ArenaSlotInfo};

    let snap = ArenaSnapshot {
        user_vm_start: 0x10_0000_0000,
        ..ArenaSnapshot::default()
    };
    let mut index = ArenaSlotIndex::new();
    index.insert(
        0x0000_1000,
        ArenaSlotInfo {
            elem_size: 16,
            header_size: 8,
            target_type_id: 7,
            source_btf_kva: 0,
        },
    );

    let r = ResolveArenaTypeStub {
        arena_snapshot: Some(&snap),
        arena_slot_index: Some(&index),
    };

    // Below the window: low-32 maps inside the seeded slot but
    // is_arena_addr rejects.
    assert!(
        r.resolve_arena_type(0x0F_0000_1008).is_none(),
        "below-window address must NOT resolve regardless of low-32 collision",
    );
    // Above the window: same gate.
    assert!(
        r.resolve_arena_type(0x12_0000_1008).is_none(),
        "above-window address must NOT resolve regardless of low-32 collision",
    );
}

/// `resolve_arena_type` returns `None` when the index is absent
/// (`arena_slot_index = None`): the `?` short-circuit fires before
/// the gate. Production path: a scheduler that does not link
/// sdt_alloc leaves the index empty; the renderer falls back to
/// the trait default's "no bridge" behaviour.
#[test]
fn accessor_mem_reader_resolve_arena_type_none_index_short_circuits() {
    use super::super::arena::ArenaSnapshot;

    let snap = ArenaSnapshot {
        user_vm_start: 0x10_0000_0000,
        ..ArenaSnapshot::default()
    };
    let r = ResolveArenaTypeStub {
        arena_snapshot: Some(&snap),
        arena_slot_index: None,
    };

    // Even an in-window address returns None when the index is
    // absent — the `?` operator on `self.arena_slot_index` fires
    // before the gate runs.
    assert!(
        r.resolve_arena_type(0x10_0000_1008).is_none(),
        "None index must short-circuit before is_arena_addr gate",
    );
}

/// Exact slot-end boundary: the address immediately following the
/// slot's last byte (`slot_start + elem_size`) is OUT of range and
/// must not resolve. Pins the `<` (not `<=`) bound check that keeps
/// adjacent-slot lookups from spuriously hitting the prior slot.
#[test]
fn accessor_mem_reader_resolve_arena_type_slot_end_boundary_excluded() {
    use super::super::arena::ArenaSnapshot;
    use super::render_map::{ArenaSlotIndex, ArenaSlotInfo};

    let snap = ArenaSnapshot {
        user_vm_start: 0x10_0000_0000,
        ..ArenaSnapshot::default()
    };
    let mut index = ArenaSlotIndex::new();
    // One slot at 0x1000 with elem_size=16 → range
    // [0x1000, 0x1010). 0x100F is the last byte; 0x1010 is the
    // first byte of the next (uninstalled) slot.
    index.insert(
        0x0000_1000,
        ArenaSlotInfo {
            elem_size: 16,
            header_size: 8,
            target_type_id: 7,
            source_btf_kva: 0,
        },
    );

    let r = ResolveArenaTypeStub {
        arena_snapshot: Some(&snap),
        arena_slot_index: Some(&index),
    };

    // Last byte of the slot (offset = 15 = elem_size - 1): inside
    // the range; falls to the mid-payload branch and returns None
    // because the bridge does not render mid-struct.
    assert!(
        r.resolve_arena_type(0x10_0000_100F).is_none(),
        "last byte of slot must not resolve (mid-payload offset)",
    );
    // Exactly slot_start + elem_size: OUT of range. The `<`
    // comparison rejects.
    assert!(
        r.resolve_arena_type(0x10_0000_1010).is_none(),
        "slot_start + elem_size must not resolve (boundary excluded)",
    );
}

/// Adjacent slots with no gap (`slot_a_end == slot_b_start`): the
/// range lookup picks each slot for its own range; the exact
/// boundary belongs to the second slot, not the first. Pins the
/// behaviour of `BTreeMap::range(..=key).next_back()` against an
/// off-by-one regression where the prior slot might "win" on the
/// next slot's first byte.
#[test]
fn accessor_mem_reader_resolve_arena_type_adjacent_slots_picked_correctly() {
    use super::super::arena::ArenaSnapshot;
    use super::super::btf_render::ArenaResolveHit;
    use super::render_map::{ArenaSlotIndex, ArenaSlotInfo};

    let snap = ArenaSnapshot {
        user_vm_start: 0x10_0000_0000,
        ..ArenaSnapshot::default()
    };
    let mut index = ArenaSlotIndex::new();
    // Slot A at 0x1000, elem_size=16 → range [0x1000, 0x1010).
    index.insert(
        0x0000_1000,
        ArenaSlotInfo {
            elem_size: 16,
            header_size: 8,
            target_type_id: 7,
            source_btf_kva: 0,
        },
    );
    // Slot B at 0x1010, elem_size=16 → range [0x1010, 0x1020).
    // Adjacent, no gap.
    index.insert(
        0x0000_1010,
        ArenaSlotInfo {
            elem_size: 16,
            header_size: 8,
            target_type_id: 11,
            source_btf_kva: 0,
        },
    );

    let r = ResolveArenaTypeStub {
        arena_snapshot: Some(&snap),
        arena_slot_index: Some(&index),
    };

    // Slot A start (offset 0): payload type 7 with
    // `header_skip = 8`.
    assert_eq!(
        r.resolve_arena_type(0x10_0000_1000),
        Some(ArenaResolveHit {
            target_type_id: 7,
            header_skip: 8,
        }),
    );
    // Slot A payload-start (offset 8): payload type 7 with
    // `header_skip = 0`.
    assert_eq!(
        r.resolve_arena_type(0x10_0000_1008),
        Some(ArenaResolveHit {
            target_type_id: 7,
            header_skip: 0,
        }),
    );
    // Slot B start (offset 0 within B; this is `slot_a_end`):
    // payload type 11 — slot B wins, NOT slot A.
    assert_eq!(
        r.resolve_arena_type(0x10_0000_1010),
        Some(ArenaResolveHit {
            target_type_id: 11,
            header_skip: 8,
        }),
    );
    // Slot B payload-start.
    assert_eq!(
        r.resolve_arena_type(0x10_0000_1018),
        Some(ArenaResolveHit {
            target_type_id: 11,
            header_skip: 0,
        }),
    );
}

/// High-edge slot near `u32::MAX`: a slot whose `slot_start +
/// elem_size` would overflow `u32` must still resolve correctly
/// for in-range addresses. Pins the `u64`-widened bound that
/// replaced the old `u32::checked_add` (which silently dropped
/// the last few KiB of a 4 GiB arena window).
#[test]
fn accessor_mem_reader_resolve_arena_type_high_edge_slot_resolves() {
    use super::super::arena::ArenaSnapshot;
    use super::super::btf_render::ArenaResolveHit;
    use super::render_map::{ArenaSlotIndex, ArenaSlotInfo};

    let snap = ArenaSnapshot {
        user_vm_start: 0x10_0000_0000,
        ..ArenaSnapshot::default()
    };
    let mut index = ArenaSlotIndex::new();
    // Slot near the top of the 4 GiB window. slot_start +
    // elem_size = 0xFFFF_F000 + 4096 = 0x1_0000_0000, which
    // overflows u32. The widened bound resolves both endpoints
    // correctly.
    index.insert(
        0xFFFF_F000,
        ArenaSlotInfo {
            elem_size: 4096,
            header_size: 8,
            target_type_id: 7,
            source_btf_kva: 0,
        },
    );

    let r = ResolveArenaTypeStub {
        arena_snapshot: Some(&snap),
        arena_slot_index: Some(&index),
    };

    // Slot start: full 64-bit address re-attaches the high 32 bits
    // from `user_vm_start` (0x10_0000_0000) onto the windowed slot
    // start (0xFFFF_F000) → 0x10_FFFF_F000.
    assert_eq!(
        r.resolve_arena_type(0x10_FFFF_F000),
        Some(ArenaResolveHit {
            target_type_id: 7,
            header_skip: 8,
        }),
    );
    // Last byte inside the slot: low-32 = 0xFFFF_FFFF (=
    // slot_start + elem_size - 1). Mid-payload offset → None
    // (the bridge does not render mid-struct). The critical
    // assertion is that the bound check did NOT reject this
    // address as "outside the slot" — the wide arithmetic kept
    // the comparison meaningful.
    assert!(
        r.resolve_arena_type(0x10_FFFF_FFFF).is_none(),
        "last byte must reach the mid-payload branch (None), \
         not be rejected as out-of-range by overflow",
    );
}

// -- resolve_arena_type_with_static_fallback ---------------------
//
// The fall-through helper composes the per-instance sdt_alloc index
// (typed) with the scx_static range index (membership-only). When
// the sdt_alloc index resolves the chase, the result is returned
// verbatim; when sdt_alloc misses but the address falls in a live
// scx_static region, the helper deliberately returns `None` (the
// "no invalid data made" contract — the bridge cannot recover a
// per-allocation type from scx_static memory).

/// sdt_alloc hit path: the helper returns the same `ArenaResolveHit`
/// that `resolve_arena_type_in_index` would. Pinning the
/// "fall-through doesn't reorder behaviour" invariant — adding the
/// scx_static fall-through must not change the output for any
/// address the inner index resolves.
#[test]
fn resolve_arena_type_with_static_fallback_returns_sdt_alloc_hit() {
    use super::super::arena::ArenaSnapshot;
    use super::super::btf_render::ArenaResolveHit;
    use super::super::scx_static_alloc::{ScxStaticRangeIndex, ScxStaticSnapshot};
    use super::render_map::{ArenaSlotIndex, ArenaSlotInfo};

    let snap = ArenaSnapshot {
        user_vm_start: 0x10_0000_0000,
        ..ArenaSnapshot::default()
    };
    let mut sdt_index = ArenaSlotIndex::new();
    sdt_index.insert(
        0x0000_1000,
        ArenaSlotInfo {
            elem_size: 24,
            header_size: 8,
            target_type_id: 7,
            source_btf_kva: 0,
        },
    );
    // scx_static index intentionally empty for this test; the
    // sdt_alloc hit must fire first regardless.
    let static_index: ScxStaticRangeIndex =
        super::super::scx_static_alloc::build_scx_static_range_index(&ScxStaticSnapshot::default());

    let hit = super::render_map::resolve_arena_type_with_static_fallback(
        Some(&snap),
        Some(&sdt_index),
        Some(&static_index),
        0x10_0000_1000, // slot start
        0,
    );
    assert_eq!(
        hit,
        Some(ArenaResolveHit {
            target_type_id: 7,
            header_skip: 8,
        }),
        "sdt_alloc index hit must propagate through fallback helper unchanged",
    );
}

/// scx_static-only hit path: when sdt_alloc misses AND the address
/// falls inside a live `scx_static` range, the helper returns
/// `None` deliberately — the bridge has no per-allocation type to
/// recover. Pinning the fail-closed contract.
#[test]
fn resolve_arena_type_with_static_fallback_scx_static_hit_returns_none() {
    use super::super::arena::ArenaSnapshot;
    use super::super::scx_static_alloc::{ScxStaticRange, ScxStaticSnapshot};

    let snap = ArenaSnapshot {
        user_vm_start: 0x10_0000_0000,
        ..ArenaSnapshot::default()
    };
    // No sdt_alloc index → every address misses sdt_alloc.
    let scx_static_snap = ScxStaticSnapshot {
        ranges: vec![ScxStaticRange {
            instance_name: "scx_static".into(),
            start_low32: 0x2000,
            size: 4096,
            capacity: 8192,
        }],
        skipped: 0,
    };
    let static_index =
        super::super::scx_static_alloc::build_scx_static_range_index(&scx_static_snap);

    // Address inside scx_static range. sdt_alloc misses; scx_static
    // hits; helper returns None.
    let hit = super::render_map::resolve_arena_type_with_static_fallback(
        Some(&snap),
        None,
        Some(&static_index),
        0x10_0000_2010,
        0,
    );
    assert!(
        hit.is_none(),
        "scx_static-only hit must return None — bridge cannot recover \
         per-allocation type without per-call-site hook from cast analysis",
    );
}

/// Out-of-window address with both indexes seeded → None.
/// `is_arena_addr_in_snapshot` gates the sdt_alloc lookup, and
/// the scx_static fall-through also gates on the same window. An
/// address whose low-32 lands in either index but whose full
/// 64-bit value lives outside the arena window must NOT spuriously
/// hit either path.
#[test]
fn resolve_arena_type_with_static_fallback_out_of_window_returns_none() {
    use super::super::arena::ArenaSnapshot;
    use super::super::scx_static_alloc::{ScxStaticRange, ScxStaticSnapshot};
    use super::render_map::{ArenaSlotIndex, ArenaSlotInfo};

    let snap = ArenaSnapshot {
        user_vm_start: 0x10_0000_0000,
        ..ArenaSnapshot::default()
    };
    // Both indexes have entries.
    let mut sdt_index = ArenaSlotIndex::new();
    sdt_index.insert(
        0x0000_1000,
        ArenaSlotInfo {
            elem_size: 24,
            header_size: 8,
            target_type_id: 7,
            source_btf_kva: 0,
        },
    );
    let scx_static_snap = ScxStaticSnapshot {
        ranges: vec![ScxStaticRange {
            instance_name: "scx_static".into(),
            start_low32: 0x2000,
            size: 4096,
            capacity: 8192,
        }],
        skipped: 0,
    };
    let static_index =
        super::super::scx_static_alloc::build_scx_static_range_index(&scx_static_snap);

    // Address has correct low-32 to hit sdt_alloc but high bits
    // are outside the window.
    let hit = super::render_map::resolve_arena_type_with_static_fallback(
        Some(&snap),
        Some(&sdt_index),
        Some(&static_index),
        0x05_0000_1000,
        0,
    );
    assert!(
        hit.is_none(),
        "out-of-window address must NOT hit sdt_alloc even with low-32 collision",
    );

    // Same idea, low-32 hits scx_static.
    let hit = super::render_map::resolve_arena_type_with_static_fallback(
        Some(&snap),
        Some(&sdt_index),
        Some(&static_index),
        0x05_0000_2010,
        0,
    );
    assert!(
        hit.is_none(),
        "out-of-window address must NOT hit scx_static even with low-32 collision",
    );
}

/// Both indexes None → behaves exactly like the trait default
/// (return None). Pinning that the helper short-circuits cleanly
/// when neither index is wired.
#[test]
fn resolve_arena_type_with_static_fallback_both_none_returns_none() {
    use super::super::arena::ArenaSnapshot;

    let snap = ArenaSnapshot {
        user_vm_start: 0x10_0000_0000,
        ..ArenaSnapshot::default()
    };
    let hit = super::render_map::resolve_arena_type_with_static_fallback(
        Some(&snap),
        None,
        None,
        0x10_0000_1000,
        0,
    );
    assert!(
        hit.is_none(),
        "both-None must return None — same as trait default",
    );
}

// -- ArenaSnapshot.user_vm_start serde + Display ------------------
//
// The new field is preserved across serde encode/decode and
// present even when the snapshot bailed before reading
// `kern_vm_kva` (preserves the arena anchor for is_arena_addr
// consumers).

#[test]
fn arena_snapshot_user_vm_start_round_trips() {
    use super::super::arena::ArenaSnapshot;
    let snap = ArenaSnapshot {
        user_vm_start: 0x1234_5678_0000_0000,
        ..ArenaSnapshot::default()
    };
    let json = serde_json::to_string(&snap).expect("serialize");
    assert!(
        json.contains("\"user_vm_start\":1311768464867721216"),
        "user_vm_start in JSON: {json}",
    );
    let parsed: ArenaSnapshot = serde_json::from_str(&json).expect("deserialize");
    assert_eq!(parsed.user_vm_start, 0x1234_5678_0000_0000);
}

#[test]
fn arena_snapshot_default_user_vm_start_is_zero() {
    use super::super::arena::ArenaSnapshot;
    let snap = ArenaSnapshot::default();
    assert_eq!(
        snap.user_vm_start, 0,
        "default snapshot's user_vm_start is 0 (no anchor)",
    );
}

// -- render_map refactor regression tests -------------------------
//
// The refactor extracted `render_value_or_hex` and
// `render_key_optional` from 6+ duplicated match arms in
// `render_map`, and pushed wildcard explanation strings into the
// `MAP_TYPE_EXPLANATIONS` lookup table. Pin the dispatch shape of
// both helpers and the table-vs-explicit-arm coverage so a
// future refactor that drops a discriminant from the table or
// re-adds the (None, _) case to the BTF render path trips a test
// before the dump loses fidelity.

/// Empty MemReader for tests that don't need pointer-deref. The
/// helpers under test only forward the `&dyn MemReader` to
/// `render_value_with_mem`; for the (None btf) and (type_id=0)
/// paths the MemReader is never consulted.
struct EmptyReader;
impl super::super::btf_render::MemReader for EmptyReader {
    fn read_kva(&self, _: u64, _: usize) -> Option<Vec<u8>> {
        None
    }
}

/// `render_value_or_hex` falls back to `RenderedValue::Bytes`
/// when no Btf is provided. Hex output matches `hex_dump` so a
/// consumer scanning the failure dump always sees raw bytes for
/// maps whose value type id couldn't be resolved.
#[test]
fn render_value_or_hex_falls_back_to_hex_when_btf_none() {
    let bytes = [0x12u8, 0x34, 0x56];
    let reader = EmptyReader;
    let rendered = render_value_or_hex(None, 0, &bytes, &reader);
    match rendered {
        RenderedValue::Bytes { hex } => {
            assert_eq!(hex, "12 34 56", "hex must match hex_dump output");
        }
        other => panic!("expected Bytes, got {other:?}"),
    }
}

/// `render_value_or_hex` also falls back to `Bytes` when a Btf
/// IS provided but the type id is zero (the kernel libbpf
/// signal for "no BTF type recorded for this slot"). The match
/// arm guard `id != 0` enforces this.
#[test]
fn render_value_or_hex_falls_back_to_hex_when_type_id_zero() {
    let Some(path) = crate::monitor::find_test_vmlinux() else {
        return;
    };
    let Ok(btf) = crate::monitor::btf_offsets::load_btf_from_path(&path) else {
        crate::report::test_skip("could not parse vmlinux BTF");
        return;
    };
    let bytes = [0xABu8, 0xCD];
    let reader = EmptyReader;
    let rendered = render_value_or_hex(Some(&btf), 0, &bytes, &reader);
    match rendered {
        RenderedValue::Bytes { hex } => {
            assert_eq!(hex, "ab cd", "type_id=0 must surface hex even with btf");
        }
        other => panic!("expected Bytes, got {other:?}"),
    }
}

/// `render_value_or_hex` produces a typed render when both Btf
/// and a non-zero type id are available. Uses the kernel BTF's
/// `int` type, which the renderer decodes as `RenderedValue::Int`.
#[test]
fn render_value_or_hex_renders_via_btf_when_present() {
    let Some(path) = crate::monitor::find_test_vmlinux() else {
        return;
    };
    let Ok(btf) = crate::monitor::btf_offsets::load_btf_from_path(&path) else {
        crate::report::test_skip("could not parse vmlinux BTF");
        return;
    };
    let Ok(ids) = btf.resolve_ids_by_name("int") else {
        crate::report::test_skip("BTF missing 'int' type");
        return;
    };
    let Some(&id) = ids.first() else {
        crate::report::test_skip("BTF resolved 'int' to empty id list");
        return;
    };
    // Little-endian 0x42 as a 4-byte int.
    let bytes = 0x42i32.to_le_bytes();
    let reader = EmptyReader;
    let rendered = render_value_or_hex(Some(&btf), id, &bytes, &reader);
    match rendered {
        RenderedValue::Int { bits: 32, value } => {
            assert_eq!(value, 0x42, "BTF render must surface the decoded int value");
        }
        other => panic!("expected Int{{bits:32}}, got {other:?}"),
    }
}

/// `render_key_optional` returns None when no Btf is provided.
/// The hash-map key path keeps `key_hex` regardless and only
/// surfaces a typed render when both Btf and a non-zero type id
/// allow it, so the None-btf branch must NOT silently fall back
/// to `Bytes` like its value-side sibling.
#[test]
fn render_key_optional_returns_none_when_btf_none() {
    let bytes = [0x07u8, 0x00, 0x00, 0x00];
    let reader = EmptyReader;
    let rendered = render_key_optional(None, 0, &bytes, &reader);
    assert!(
        rendered.is_none(),
        "None btf must surface as None: {rendered:?}"
    );
}

/// `render_key_optional` returns None even with a Btf when the
/// type id is zero. Exercises the `id != 0` guard symmetric with
/// `render_value_or_hex_falls_back_to_hex_when_type_id_zero`.
#[test]
fn render_key_optional_returns_none_when_type_id_zero() {
    let Some(path) = crate::monitor::find_test_vmlinux() else {
        return;
    };
    let Ok(btf) = crate::monitor::btf_offsets::load_btf_from_path(&path) else {
        crate::report::test_skip("could not parse vmlinux BTF");
        return;
    };
    let bytes = [0x07u8, 0x00, 0x00, 0x00];
    let reader = EmptyReader;
    let rendered = render_key_optional(Some(&btf), 0, &bytes, &reader);
    assert!(
        rendered.is_none(),
        "type_id=0 must surface as None even with btf: {rendered:?}",
    );
}

/// `render_key_optional` returns Some(rendered) when both Btf
/// and a non-zero type id are available — the only path that
/// surfaces a typed key.
#[test]
fn render_key_optional_returns_some_via_btf() {
    let Some(path) = crate::monitor::find_test_vmlinux() else {
        return;
    };
    let Ok(btf) = crate::monitor::btf_offsets::load_btf_from_path(&path) else {
        crate::report::test_skip("could not parse vmlinux BTF");
        return;
    };
    let Ok(ids) = btf.resolve_ids_by_name("int") else {
        crate::report::test_skip("BTF missing 'int' type");
        return;
    };
    let Some(&id) = ids.first() else {
        crate::report::test_skip("BTF resolved 'int' to empty id list");
        return;
    };
    let bytes = 0x99i32.to_le_bytes();
    let reader = EmptyReader;
    let rendered = render_key_optional(Some(&btf), id, &bytes, &reader);
    match rendered {
        Some(RenderedValue::Int { bits: 32, value }) => {
            assert_eq!(value, 0x99, "must surface the decoded int value");
        }
        other => panic!("expected Some(Int{{bits:32}}), got {other:?}"),
    }
}

/// `find_sdt_data_field_offset` returns `None` for `value_type_id == 0`
/// without consulting BTF — pins the explicit early-return so a future
/// caller passing the kernel-libbpf "no BTF" sentinel doesn't trip a
/// spurious BTF probe.
#[test]
fn find_sdt_data_field_offset_zero_type_id_short_circuits() {
    let Some(path) = crate::monitor::find_test_vmlinux() else {
        return;
    };
    let Ok(btf) = crate::monitor::btf_offsets::load_btf_from_path(&path) else {
        crate::report::test_skip("could not parse vmlinux BTF");
        return;
    };
    assert_eq!(
        super::render_map::find_sdt_data_field_offset(&btf, 0),
        None,
        "type_id=0 must short-circuit to None",
    );
}

/// `find_sdt_data_field_offset` happy path: a synthetic value-type
/// struct that DOES carry a `struct sdt_data __arena *` member at a
/// known byte offset must return `Some(offset)`. The pointee is
/// declared as `BTF_KIND_FWD struct sdt_data` (the lavd-and-similar
/// shape — see the FWD-pointee branch in
/// `crate::monitor::render_map::find_sdt_data_field_offset` for the
/// production walk this test guards). Without this test, every
/// existing test only pins None-paths — an implementation that
/// always returns None silently passes them all.
#[test]
fn find_sdt_data_field_offset_returns_offset_for_fwd_pointee() {
    use std::io::Write;
    // BTF layout:
    //   id 1: Int u64 (8 bytes)
    //   id 2: Fwd struct sdt_data
    //   id 3: Ptr -> id 2
    //   id 4: Struct value_t { u64 a @ 0; sdt_data __arena * data @ 8 }
    //         (size = 16)
    //
    // Wire format constants (kernel uapi linux/btf.h).
    const BTF_KIND_INT: u32 = 1;
    const BTF_KIND_PTR: u32 = 2;
    const BTF_KIND_STRUCT: u32 = 4;
    const BTF_KIND_FWD: u32 = 7;
    let mut strings: Vec<u8> = vec![0];
    let push = |s: &mut Vec<u8>, name: &str| -> u32 {
        let off = s.len() as u32;
        s.extend_from_slice(name.as_bytes());
        s.push(0);
        off
    };
    let n_u64 = push(&mut strings, "u64");
    let n_sdt_data = push(&mut strings, "sdt_data");
    let n_value = push(&mut strings, "value_t");
    let n_a = push(&mut strings, "a");
    let n_data = push(&mut strings, "data");

    let mut types: Vec<u8> = Vec::new();
    // id 1: Int u64.
    types.extend_from_slice(&n_u64.to_le_bytes());
    let int_info = (BTF_KIND_INT << 24) & 0x1f00_0000;
    types.extend_from_slice(&int_info.to_le_bytes());
    types.extend_from_slice(&8u32.to_le_bytes()); // size
    types.extend_from_slice(&64u32.to_le_bytes()); // encoding=0,offset=0,bits=64
    // id 2: Fwd struct sdt_data (kind_flag=0 → struct flavour).
    types.extend_from_slice(&n_sdt_data.to_le_bytes());
    let fwd_info = (BTF_KIND_FWD << 24) & 0x1f00_0000;
    types.extend_from_slice(&fwd_info.to_le_bytes());
    types.extend_from_slice(&0u32.to_le_bytes()); // size_type unused for Fwd
    // id 3: Ptr -> id 2 (sdt_data Fwd).
    types.extend_from_slice(&0u32.to_le_bytes()); // name_off (anonymous)
    let ptr_info = (BTF_KIND_PTR << 24) & 0x1f00_0000;
    types.extend_from_slice(&ptr_info.to_le_bytes());
    types.extend_from_slice(&2u32.to_le_bytes()); // type id of pointee
    // id 4: Struct value_t { u64 a @ 0; sdt_data __arena * data @ 8 }
    //                 size = 16, vlen = 2.
    types.extend_from_slice(&n_value.to_le_bytes());
    let struct_info = ((BTF_KIND_STRUCT << 24) & 0x1f00_0000) | 2u32; // vlen=2
    types.extend_from_slice(&struct_info.to_le_bytes());
    types.extend_from_slice(&16u32.to_le_bytes()); // size
    // member 0: a @ bit 0 (byte 0), type id 1 (u64).
    types.extend_from_slice(&n_a.to_le_bytes());
    types.extend_from_slice(&1u32.to_le_bytes());
    types.extend_from_slice(&0u32.to_le_bytes());
    // member 1: data @ bit 64 (byte 8), type id 3 (Ptr).
    types.extend_from_slice(&n_data.to_le_bytes());
    types.extend_from_slice(&3u32.to_le_bytes());
    types.extend_from_slice(&64u32.to_le_bytes());

    let type_len = types.len() as u32;
    let str_len = strings.len() as u32;
    let mut blob: Vec<u8> = Vec::new();
    blob.write_all(&0xEB9F_u16.to_le_bytes()).unwrap(); // magic
    blob.push(1); // version
    blob.push(0); // flags
    blob.write_all(&24u32.to_le_bytes()).unwrap(); // hdr_len
    blob.write_all(&0u32.to_le_bytes()).unwrap(); // type_off
    blob.write_all(&type_len.to_le_bytes()).unwrap();
    blob.write_all(&type_len.to_le_bytes()).unwrap(); // str_off = type_len
    blob.write_all(&str_len.to_le_bytes()).unwrap();
    blob.extend_from_slice(&types);
    blob.extend_from_slice(&strings);

    let btf = btf_rs::Btf::from_bytes(&blob).expect("synthetic BTF parses");
    // Value type id is 4. The helper must walk to member `data`,
    // peel modifiers, find Ptr → Fwd "sdt_data", and return byte 8.
    let value_type_id: u32 = 4;
    assert_eq!(
        super::render_map::find_sdt_data_field_offset(&btf, value_type_id),
        Some(8),
        "value_t struct carrying `struct sdt_data __arena * data @ 8` \
         must resolve to Some(8); the helper has only None-path tests \
         today, so an implementation that always returns None silently \
         passes the existing suite without this test",
    );
}

/// `find_sdt_data_field_offset` returns `None` for a struct that has no
/// `struct sdt_data __arena *` member — vmlinux's `task_struct` is
/// guaranteed to predate scx and therefore can't carry a member with
/// that pointee. A non-None return on `task_struct` would mean the
/// helper is matching on something other than the pointee struct name.
#[test]
fn find_sdt_data_field_offset_none_for_unrelated_struct() {
    let Some(path) = crate::monitor::find_test_vmlinux() else {
        return;
    };
    let Ok(btf) = crate::monitor::btf_offsets::load_btf_from_path(&path) else {
        crate::report::test_skip("could not parse vmlinux BTF");
        return;
    };
    let Ok(ids) = btf.resolve_ids_by_name("task_struct") else {
        crate::report::test_skip("vmlinux BTF missing 'task_struct'");
        return;
    };
    let Some(&id) = ids.first() else {
        crate::report::test_skip("'task_struct' resolved to empty id list");
        return;
    };
    assert_eq!(
        super::render_map::find_sdt_data_field_offset(&btf, id),
        None,
        "task_struct must not match the sdt_data pointee predicate",
    );
}

/// `chase_sdt_data_payload` returns `None` whenever any of its
/// prerequisite inputs is missing: no BTF, no field offset, no
/// allocator metadata, zero `target_type_id`,
/// `elem_size <= header_size`, or `kern_vm_start == 0`. Each
/// early-return is one of the gates the surface render relies on
/// to NOT spuriously decorate non-arena entries.
#[test]
fn chase_sdt_data_payload_returns_none_on_missing_prereqs() {
    use super::super::btf_render::MemReader;
    use super::render_map::{SdtAllocMeta, chase_sdt_data_payload};
    struct StubReader;
    impl MemReader for StubReader {
        // Returns a zero buffer for every page-table-walked KVA
        // read so the gates we're testing get exercised regardless
        // of the synthetic kva. The gates this test exercises all
        // short-circuit BEFORE the read_kva call, so the contents
        // here are immaterial — the tests pin that the gates fire,
        // not the post-gate render.
        fn read_kva(&self, _: u64, len: usize) -> Option<Vec<u8>> {
            Some(vec![0u8; len])
        }
    }
    let reader = StubReader;
    let Some(path) = crate::monitor::find_test_vmlinux() else {
        return;
    };
    let Ok(btf) = crate::monitor::btf_offsets::load_btf_from_path(&path) else {
        crate::report::test_skip("could not parse vmlinux BTF");
        return;
    };
    // We use any non-zero u32 as a stand-in for a payload type id —
    // the helper short-circuits BEFORE rendering when the elem_size
    // gate fails, so the type id is never dereferenced in those
    // arms.
    let placeholder_type_id: u32 = 1;
    // Every `Some` SdtAllocMeta in this test uses a non-zero
    // `kern_vm_start` so the kern_vm_start gate doesn't pre-empt
    // the gate under test; the dedicated kern_vm_start=0 case
    // appears below.
    let valid_meta = SdtAllocMeta {
        allocator_name: "scx_test_allocator".into(),
        elem_size: 32,
        header_size: 8,
        target_type_id: placeholder_type_id,
        kern_vm_start: 0xFFFF_8000_0000_0000,
    };
    // 24 bytes of value bytes: tid (8) + tptr (8) + data (8 = pointer
    // 0x100000000 in LE).
    let mut value_bytes = vec![0u8; 24];
    value_bytes[16..24].copy_from_slice(&0x1_0000_0000u64.to_le_bytes());

    // No BTF.
    assert!(
        chase_sdt_data_payload(None, Some(16), Some(&valid_meta), &value_bytes, &reader).is_none(),
        "missing btf must yield None",
    );
    // No field offset.
    assert!(
        chase_sdt_data_payload(Some(&btf), None, Some(&valid_meta), &value_bytes, &reader)
            .is_none(),
        "missing field offset must yield None",
    );
    // No allocator metadata.
    assert!(
        chase_sdt_data_payload(Some(&btf), Some(16), None, &value_bytes, &reader).is_none(),
        "missing allocator metadata must yield None",
    );
    // Zero target_type_id (allocator pre-pass returned 0 for
    // ambiguous / no-candidate paths).
    let zero_payload = SdtAllocMeta {
        target_type_id: 0,
        ..valid_meta.clone()
    };
    assert!(
        chase_sdt_data_payload(
            Some(&btf),
            Some(16),
            Some(&zero_payload),
            &value_bytes,
            &reader,
        )
        .is_none(),
        "target_type_id=0 must yield None",
    );
    // elem_size <= header_size: corrupt allocator metadata; payload
    // would slice empty.
    let small_elem = SdtAllocMeta {
        elem_size: 8,
        ..valid_meta.clone()
    };
    assert!(
        chase_sdt_data_payload(
            Some(&btf),
            Some(16),
            Some(&small_elem),
            &value_bytes,
            &reader,
        )
        .is_none(),
        "elem_size <= header_size must yield None",
    );
    // kern_vm_start == 0: arena pre-pass found no kernel-side
    // anchor — the chase has no way to compute a KVA from the
    // user-side pointer.
    let no_kern_vm = SdtAllocMeta {
        kern_vm_start: 0,
        ..valid_meta.clone()
    };
    assert!(
        chase_sdt_data_payload(
            Some(&btf),
            Some(16),
            Some(&no_kern_vm),
            &value_bytes,
            &reader,
        )
        .is_none(),
        "kern_vm_start=0 must yield None",
    );
    // Null arena pointer: scx_task_storage entry created but
    // scx_task_alloc has not populated the data slot yet.
    let mut zero_value_bytes = vec![0u8; 24];
    // explicit zero at offset 16..24 — already zero, but pin
    // intent.
    zero_value_bytes[16..24].copy_from_slice(&0u64.to_le_bytes());
    assert!(
        chase_sdt_data_payload(
            Some(&btf),
            Some(16),
            Some(&valid_meta),
            &zero_value_bytes,
            &reader,
        )
        .is_none(),
        "data_ptr=0 must yield None",
    );
    // Value bytes too short to hold a u64 at the field offset.
    let short_value_bytes = vec![0u8; 20];
    assert!(
        chase_sdt_data_payload(
            Some(&btf),
            Some(16),
            Some(&valid_meta),
            &short_value_bytes,
            &reader,
        )
        .is_none(),
        "value bytes too short for pointer read must yield None",
    );
}

/// `chase_sdt_data_payload` returns `None` when the page-table
/// walker can't resolve the composed KVA. Pages outside the
/// captured guest memory (PA past end-of-DRAM) translate to a
/// failure, matching the `read_kva_bytes_chunked` semantics
/// `mem_reader.read_kva` is built atop.
#[test]
fn chase_sdt_data_payload_yields_none_on_unmapped_kva() {
    use super::super::btf_render::MemReader;
    use super::render_map::{SdtAllocMeta, chase_sdt_data_payload};
    struct UnmappedReader;
    impl MemReader for UnmappedReader {
        // Always returns None — every read_kva fails. Mirrors a
        // page-table walker that has no PTE for the requested KVA
        // (an arena page that's been freed, or a KVA outside the
        // arena window).
        fn read_kva(&self, _: u64, _: usize) -> Option<Vec<u8>> {
            None
        }
    }
    let reader = UnmappedReader;
    let Some(path) = crate::monitor::find_test_vmlinux() else {
        return;
    };
    let Ok(btf) = crate::monitor::btf_offsets::load_btf_from_path(&path) else {
        crate::report::test_skip("could not parse vmlinux BTF");
        return;
    };
    let mut value_bytes = vec![0u8; 24];
    value_bytes[16..24].copy_from_slice(&0x1_0000_0000u64.to_le_bytes());
    let meta = SdtAllocMeta {
        allocator_name: "scx_test_allocator".into(),
        elem_size: 32,
        header_size: 8,
        target_type_id: 1,
        kern_vm_start: 0xFFFF_8000_0000_0000,
    };
    assert!(
        chase_sdt_data_payload(Some(&btf), Some(16), Some(&meta), &value_bytes, &reader,).is_none(),
        "unmapped kva must yield None even with all other prereqs satisfied",
    );
}

/// `FailureDumpEntry::payload` round-trips through serde JSON when
/// populated, and is suppressed by `skip_serializing_if` when None
/// (preserves the wire shape for entries that don't carry a typed
/// payload).
#[test]
fn failure_dump_entry_payload_serde_roundtrip() {
    let entry = FailureDumpEntry {
        key: None,
        key_hex: "00 11 22 33 44 55 66 77".into(),
        value: None,
        value_hex: "AA BB".into(),
        payload: Some(RenderedValue::Uint {
            bits: 64,
            value: 0xDEAD_BEEF,
        }),
    };
    let json = serde_json::to_string(&entry).unwrap();
    assert!(
        json.contains("\"payload\""),
        "populated payload must appear in JSON: {json}",
    );
    let parsed: FailureDumpEntry = serde_json::from_str(&json).unwrap();
    match parsed.payload {
        Some(RenderedValue::Uint { bits: 64, value }) => {
            assert_eq!(value, 0xDEAD_BEEF, "value must round-trip");
        }
        other => panic!("payload didn't round-trip cleanly: {other:?}"),
    }
}

/// Empty payload must NOT appear on the wire — the
/// `skip_serializing_if = "Option::is_none"` predicate keeps the
/// JSON shape unchanged for non-arena map entries (HASH/LRU_HASH and
/// local-storage maps without a discoverable allocator).
#[test]
fn failure_dump_entry_payload_skipped_when_none() {
    let entry = FailureDumpEntry {
        key: None,
        key_hex: "00".into(),
        value: None,
        value_hex: "00".into(),
        payload: None,
    };
    let json = serde_json::to_string(&entry).unwrap();
    assert!(
        !json.contains("\"payload\""),
        "None payload must be skipped: {json}",
    );
}

/// `FailureDumpEntry` Display surfaces the typed payload underneath
/// `value`. Pin both that the payload is rendered AND the relative
/// order — operators read top-to-bottom and the surface struct
/// (key/value) must come before the deref'd payload, matching how
/// a kernel-side debugger would inspect: chase the pointer, then
/// read the dereferenced struct. The format uses
/// `.data <rendered>` (with a space, no colon) so the value's
/// own breadcrumb completes the line.
#[test]
fn failure_dump_entry_display_renders_payload_after_value() {
    let entry = FailureDumpEntry {
        key: Some(RenderedValue::Uint { bits: 64, value: 0 }),
        key_hex: "00".into(),
        value: Some(RenderedValue::Uint {
            bits: 32,
            value: 99,
        }),
        value_hex: "63".into(),
        payload: Some(RenderedValue::Uint {
            bits: 64,
            value: 0xCAFEBABE,
        }),
    };
    let out = format!("{entry}");
    assert!(
        out.contains("\n  .data "),
        "Display must label .data: {out}"
    );
    let value_pos = out.find("value:").expect("value label present");
    let payload_pos = out.find(".data ").expect(".data label present");
    assert!(
        value_pos < payload_pos,
        "Display must order value before .data: {out}",
    );
    assert!(
        out.contains("3405691582"), // 0xCAFEBABE in decimal
        "rendered payload value must appear in Display: {out}",
    );
}

/// Display omits the payload line when payload is None — the
/// existing key/value surface stays unchanged for entries that
/// don't carry a typed payload.
#[test]
fn failure_dump_entry_display_omits_payload_when_none() {
    let entry = FailureDumpEntry {
        key: None,
        key_hex: "ab".into(),
        value: None,
        value_hex: "cd".into(),
        payload: None,
    };
    let out = format!("{entry}");
    assert!(
        !out.contains("payload"),
        "Display must not surface payload when None: {out}",
    );
}

/// `MAP_TYPE_EXPLANATIONS` must carry an entry for every
/// non-walker map type so the wildcard arm produces a precise
/// reason instead of the generic "unknown map_type N" fallback.
///
/// Walker arms (have explicit handling in `render_map`):
///   ARRAY, HASH, LRU_HASH, PERCPU_HASH, LRU_PERCPU_HASH,
///   PERCPU_ARRAY, ARENA, STRUCT_OPS, TASK_STORAGE,
///   INODE_STORAGE, SK_STORAGE, CGRP_STORAGE,
///   RINGBUF, USER_RINGBUF, STACK_TRACE, and the FD-array family
///   (PROG_ARRAY, PERF_EVENT_ARRAY, CGROUP_ARRAY, ARRAY_OF_MAPS,
///   HASH_OF_MAPS, DEVMAP, DEVMAP_HASH, SOCKMAP, SOCKHASH,
///   CPUMAP, XSKMAP, REUSEPORT_SOCKARRAY).
///
/// Non-walker types must each appear in MAP_TYPE_EXPLANATIONS:
///   LPM_TRIE (11), CGROUP_STORAGE (19),
///   PERCPU_CGROUP_STORAGE (21), QUEUE (22), STACK (23),
///   BLOOM_FILTER (30), INSN_ARRAY (34).
#[test]
fn map_type_explanations_covers_every_non_walker_type() {
    let non_walker: &[(u32, &str)] = &[
        (BPF_MAP_TYPE_LPM_TRIE, "LPM_TRIE"),
        (BPF_MAP_TYPE_CGROUP_STORAGE, "CGROUP_STORAGE"),
        (BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE, "PERCPU_CGROUP_STORAGE"),
        (BPF_MAP_TYPE_QUEUE, "QUEUE"),
        (BPF_MAP_TYPE_STACK, "STACK"),
        (BPF_MAP_TYPE_BLOOM_FILTER, "BLOOM_FILTER"),
        (BPF_MAP_TYPE_INSN_ARRAY, "INSN_ARRAY"),
    ];
    for (discriminant, name) in non_walker {
        let found = MAP_TYPE_EXPLANATIONS.iter().any(|(t, _)| t == discriminant);
        assert!(
            found,
            "MAP_TYPE_EXPLANATIONS missing entry for {name} ({discriminant}); \
             wildcard arm would surface the generic 'unknown map_type' fallback",
        );
    }
}

/// Every explanation string in MAP_TYPE_EXPLANATIONS must be a
/// non-empty, actionable reason — no placeholder strings like
/// "not yet supported" / "TODO" / "FIXME" that signal the
/// implementer left work behind. The dump must always tell the
/// operator something concrete about why the map's contents are
/// missing.
#[test]
fn map_type_explanations_strings_are_actionable() {
    for (discriminant, msg) in MAP_TYPE_EXPLANATIONS {
        assert!(
            !msg.is_empty(),
            "explanation for discriminant {discriminant} is empty",
        );
        for placeholder in ["not yet supported", "TODO", "FIXME", "unimplemented"] {
            assert!(
                !msg.contains(placeholder),
                "explanation for discriminant {discriminant} contains placeholder \
                 {placeholder:?}: {msg:?}",
            );
        }
    }
}

// -- serde roundtrip tests ----------------------------------------
//
// Every new dump section (FailureDumpRingbuf, FailureDumpStackTrace,
// FailureDumpFdArray) must round-trip through serde JSON without
// mangling field semantics. Catches drift in `#[serde(default,
// skip_serializing_if = "...")]` annotations: a renamed field, a
// removed default, or a flipped condition would silently corrupt the
// wire format.

/// FailureDumpRingbuf round-trips every field (capacity, consumer_pos,
/// producer_pos, pending_pos, pending_bytes) through JSON intact.
#[test]
fn failure_dump_ringbuf_roundtrip() {
    let original = FailureDumpRingbuf {
        capacity: 0x1_0000,
        consumer_pos: 0x100,
        producer_pos: 0x200,
        pending_pos: 0x180,
        pending_bytes: 0x100,
    };
    let json = serde_json::to_string(&original).expect("ringbuf serialize");
    let restored: FailureDumpRingbuf = serde_json::from_str(&json).expect("ringbuf deserialize");
    assert_eq!(restored.capacity, original.capacity);
    assert_eq!(restored.consumer_pos, original.consumer_pos);
    assert_eq!(restored.producer_pos, original.producer_pos);
    assert_eq!(restored.pending_pos, original.pending_pos);
    assert_eq!(restored.pending_bytes, original.pending_bytes);
}

/// FailureDumpStackTrace empty case: `truncated=false` is skipped on
/// serialize (skip_serializing_if = std::ops::Not::not) and must
/// default-deserialize back to false. `entries=[]` is serialized
/// since FailureDumpStackTrace.entries has no skip annotation.
#[test]
fn failure_dump_stack_trace_empty_roundtrip() {
    let original = FailureDumpStackTrace {
        n_buckets: 0,
        entries: Vec::new(),
        truncated: false,
        buckets_unreadable: 0,
    };
    let json = serde_json::to_string(&original).expect("stack_trace serialize");
    // truncated=false should NOT appear in the wire format.
    assert!(
        !json.contains("\"truncated\":true") && !json.contains("\"truncated\":false"),
        "skip_serializing_if must elide truncated when false; JSON: {json}",
    );
    // buckets_unreadable=0 elides (is_zero_u32).
    assert!(
        !json.contains("buckets_unreadable"),
        "buckets_unreadable=0 must be elided; JSON: {json}",
    );
    let restored: FailureDumpStackTrace =
        serde_json::from_str(&json).expect("stack_trace deserialize");
    assert_eq!(restored.n_buckets, 0);
    assert!(restored.entries.is_empty());
    assert!(!restored.truncated);
    assert_eq!(restored.buckets_unreadable, 0);
}

/// FailureDumpStackTrace populated entries with truncated=true
/// preserves both the entries vector and the truncation flag.
#[test]
fn failure_dump_stack_trace_populated_roundtrip() {
    let original = FailureDumpStackTrace {
        n_buckets: 4,
        entries: vec![
            FailureDumpStackTraceEntry {
                bucket_id: 0,
                nr: 3,
                pcs: vec![
                    0xFFFF_FFFF_8100_0000,
                    0xFFFF_FFFF_8100_0010,
                    0xFFFF_FFFF_8100_0020,
                ],
                data_hex: "00 10 20".into(),
            },
            FailureDumpStackTraceEntry {
                bucket_id: 2,
                nr: 1,
                pcs: vec![0xFFFF_FFFF_8200_0000],
                data_hex: "ff".into(),
            },
        ],
        truncated: true,
        buckets_unreadable: 2,
    };
    let json = serde_json::to_string(&original).expect("stack_trace serialize");
    assert!(
        json.contains("\"truncated\":true"),
        "truncated=true must appear in JSON: {json}",
    );
    assert!(
        json.contains("\"buckets_unreadable\":2"),
        "non-zero buckets_unreadable must appear in JSON: {json}",
    );
    let restored: FailureDumpStackTrace =
        serde_json::from_str(&json).expect("stack_trace deserialize");
    assert_eq!(restored.n_buckets, 4);
    assert_eq!(restored.entries.len(), 2);
    assert_eq!(restored.entries[0].bucket_id, 0);
    assert_eq!(restored.entries[0].nr, 3);
    assert_eq!(restored.entries[0].pcs.len(), 3);
    assert_eq!(restored.entries[0].pcs[0], 0xFFFF_FFFF_8100_0000);
    assert_eq!(restored.entries[0].data_hex, "00 10 20");
    assert_eq!(restored.entries[1].bucket_id, 2);
    assert!(restored.truncated);
    assert_eq!(restored.buckets_unreadable, 2);
}

/// FailureDumpStackTraceEntry build-id mode: empty `pcs` is elided
/// from the wire format (skip_serializing_if = "Vec::is_empty"),
/// `data_hex` is always populated.
#[test]
fn failure_dump_stack_trace_entry_build_id_roundtrip() {
    let original = FailureDumpStackTraceEntry {
        bucket_id: 7,
        nr: 1,
        pcs: Vec::new(),
        data_hex: "00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f \
                   10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f"
            .into(),
    };
    let json = serde_json::to_string(&original).expect("entry serialize");
    assert!(
        !json.contains("\"pcs\""),
        "empty pcs must be elided in build-id mode; JSON: {json}",
    );
    let restored: FailureDumpStackTraceEntry =
        serde_json::from_str(&json).expect("entry deserialize");
    assert_eq!(restored.bucket_id, 7);
    assert_eq!(restored.nr, 1);
    assert!(restored.pcs.is_empty());
    assert_eq!(restored.data_hex.len(), 95); // 32 bytes * 3 chars - 1 trailing
}

/// FailureDumpFdArray empty case: `truncated=false` and `indices=[]`
/// both elided. populated/scanned remain since they have no skip
/// annotation.
#[test]
fn failure_dump_fd_array_empty_roundtrip() {
    let original = FailureDumpFdArray {
        populated: 0,
        scanned: 0,
        indices: Vec::new(),
        truncated: false,
        indices_truncated: false,
        unreadable: 0,
    };
    let json = serde_json::to_string(&original).expect("fd_array serialize");
    assert!(
        !json.contains("\"truncated\""),
        "truncated=false must be elided; JSON: {json}",
    );
    assert!(
        !json.contains("\"indices_truncated\""),
        "indices_truncated=false must be elided; JSON: {json}",
    );
    assert!(
        !json.contains("unreadable"),
        "unreadable=0 must be elided; JSON: {json}",
    );
    let restored: FailureDumpFdArray = serde_json::from_str(&json).expect("fd_array deserialize");
    assert_eq!(restored.populated, 0);
    assert_eq!(restored.scanned, 0);
    assert!(restored.indices.is_empty());
    assert!(!restored.truncated);
    assert_eq!(restored.unreadable, 0);
}

/// FailureDumpFdArray populated case: indices vector and
/// truncated=true preserved. Defensive check that populated >
/// indices.len() (the truncation-asymmetry case the implementer
/// handles by capping `indices` at MAX_FD_ARRAY_INDICES while
/// continuing to count populated slots) still round-trips coherently.
#[test]
fn failure_dump_fd_array_populated_roundtrip() {
    let original = FailureDumpFdArray {
        populated: 1500,
        scanned: 4096,
        indices: (0..1024).collect(), // capped at MAX_FD_ARRAY_INDICES
        truncated: true,
        indices_truncated: true, // 1500 > 1024
        unreadable: 5,
    };
    let json = serde_json::to_string(&original).expect("fd_array serialize");
    assert!(
        json.contains("\"indices_truncated\":true"),
        "indices_truncated=true must be emitted; JSON: {json}",
    );
    assert!(
        json.contains("\"unreadable\":5"),
        "non-zero unreadable must be emitted; JSON: {json}",
    );
    let restored: FailureDumpFdArray = serde_json::from_str(&json).expect("fd_array deserialize");
    assert_eq!(restored.populated, 1500);
    assert_eq!(restored.scanned, 4096);
    assert_eq!(restored.indices.len(), 1024);
    assert_eq!(restored.indices[0], 0);
    assert!(
        restored.indices_truncated,
        "indices_truncated must roundtrip"
    );
    assert_eq!(restored.indices[1023], 1023);
    assert!(restored.truncated);
    assert_eq!(restored.unreadable, 5);
}

/// Defaulted-from-empty deserialization: a stripped-down JSON
/// (only required fields present) deserializes with `Default`
/// values for skipped fields. Validates the
/// `#[serde(default, skip_serializing_if = "...")]` round-trip
/// across the three new dump sections.
#[test]
fn failure_dump_minimal_deserialize_uses_defaults() {
    // Ringbuf has no skip_serializing_if (every field is required),
    // so the minimal form must include all fields.
    let json =
        r#"{"capacity":0,"consumer_pos":0,"producer_pos":0,"pending_pos":0,"pending_bytes":0}"#;
    let rb: FailureDumpRingbuf = serde_json::from_str(json).expect("ringbuf minimal deserialize");
    assert_eq!(rb.capacity, 0);

    // StackTrace minimal: only n_buckets, default-fills the rest.
    let json = r#"{"n_buckets":0,"entries":[]}"#;
    let st: FailureDumpStackTrace =
        serde_json::from_str(json).expect("stack_trace minimal deserialize");
    assert_eq!(st.n_buckets, 0);
    assert!(st.entries.is_empty());
    assert!(!st.truncated, "truncated must default to false");

    // FdArray minimal: populated/scanned required, indices and
    // truncated default.
    let json = r#"{"populated":0,"scanned":0}"#;
    let fa: FailureDumpFdArray = serde_json::from_str(json).expect("fd_array minimal deserialize");
    assert_eq!(fa.populated, 0);
    assert_eq!(fa.scanned, 0);
    assert!(fa.indices.is_empty(), "indices must default to []");
    assert!(!fa.truncated, "truncated must default to false");
}

// -- DualFailureDumpReport Display rendering ----------------------
//
// display.rs distinguishes "early snapshot present with valid
// jiffies" from "early snapshot present with jiffies bookkeeping
// not captured" (both early_max_age_jiffies and
// early_threshold_jiffies are 0). Pin the wire-stable header strings
// for each branch so a reformatting regression surfaces immediately
// rather than producing operator-confusing "max_age=0j, threshold=0j"
// output.

/// Both jiffies fields are 0 with an early snapshot present:
/// renders `early=present (jiffies not captured)` instead of the
/// misleading `max_age=0j, threshold=0j`.
#[test]
fn dual_dump_display_zero_jiffies_uses_jiffies_not_captured_branch() {
    let dual = DualFailureDumpReport {
        schema: SCHEMA_DUAL.to_string(),
        early: Some(FailureDumpReport::default()),
        late: FailureDumpReport::default(),
        early_max_age_jiffies: 0,
        early_threshold_jiffies: 0,
        early_skipped_reason: None,
    };
    let rendered = format!("{dual}");
    assert!(
        rendered.contains("early=present (jiffies not captured)"),
        "zero-jiffies branch must surface a distinct phrase; got: {rendered}",
    );
    assert!(
        !rendered.contains("max_age=0j"),
        "zero-jiffies header must NOT print max_age=0j; got: {rendered}",
    );
    assert!(
        !rendered.contains("threshold=0j"),
        "zero-jiffies header must NOT print threshold=0j; got: {rendered}",
    );
}

/// Non-zero jiffies preserves the legacy `max_age={N}j, threshold={M}j`
/// format. Pins the format-string layout so a refactor that swaps
/// the field order or drops the `j` suffix surfaces immediately.
#[test]
fn dual_dump_display_nonzero_jiffies_preserves_max_age_format() {
    let dual = DualFailureDumpReport {
        schema: SCHEMA_DUAL.to_string(),
        early: Some(FailureDumpReport::default()),
        late: FailureDumpReport::default(),
        early_max_age_jiffies: 1234,
        early_threshold_jiffies: 5678,
        early_skipped_reason: None,
    };
    let rendered = format!("{dual}");
    assert!(
        rendered.contains("max_age=1234j, threshold=5678j"),
        "non-zero jiffies must preserve the max_age/threshold format; got: {rendered}",
    );
    assert!(
        !rendered.contains("jiffies not captured"),
        "non-zero jiffies must NOT use the not-captured phrase; got: {rendered}",
    );
}

/// One jiffies field zero, the other non-zero — verifies the
/// zero-jiffies branch ONLY fires when BOTH are zero. A single-zero
/// case should still use the legacy format (the bookkeeping was
/// partial but at least one number is meaningful).
#[test]
fn dual_dump_display_one_zero_one_nonzero_uses_legacy_format() {
    // max_age=0, threshold=5: legacy format with max_age=0j.
    let dual = DualFailureDumpReport {
        schema: SCHEMA_DUAL.to_string(),
        early: Some(FailureDumpReport::default()),
        late: FailureDumpReport::default(),
        early_max_age_jiffies: 0,
        early_threshold_jiffies: 5,
        early_skipped_reason: None,
    };
    let rendered = format!("{dual}");
    assert!(
        rendered.contains("max_age=0j, threshold=5j"),
        "single-zero case must use legacy format; got: {rendered}",
    );
    assert!(
        !rendered.contains("jiffies not captured"),
        "single-zero case must NOT use the not-captured phrase; got: {rendered}",
    );

    // max_age=5, threshold=0: legacy format with threshold=0j.
    let dual2 = DualFailureDumpReport {
        schema: SCHEMA_DUAL.to_string(),
        early: Some(FailureDumpReport::default()),
        late: FailureDumpReport::default(),
        early_max_age_jiffies: 5,
        early_threshold_jiffies: 0,
        early_skipped_reason: None,
    };
    let rendered2 = format!("{dual2}");
    assert!(
        rendered2.contains("max_age=5j, threshold=0j"),
        "single-zero case must use legacy format; got: {rendered2}",
    );
    assert!(
        !rendered2.contains("jiffies not captured"),
        "single-zero case must NOT use the not-captured phrase; got: {rendered2}",
    );
}

/// `early=absent` branch is independent of the jiffies fields:
/// when `early` is None, neither the legacy nor the
/// jiffies-not-captured phrase appears, even if the jiffies
/// fields are populated (they describe the absent snapshot's
/// trigger metric — surfacing them would be misleading).
#[test]
fn dual_dump_display_early_absent_omits_jiffies_lines() {
    let dual = DualFailureDumpReport {
        schema: SCHEMA_DUAL.to_string(),
        early: None,
        late: FailureDumpReport::default(),
        // Populated but unused — exercises the assertion below.
        early_max_age_jiffies: 999,
        early_threshold_jiffies: 100,
        early_skipped_reason: None,
    };
    let rendered = format!("{dual}");
    assert!(
        rendered.contains("early=absent"),
        "absent branch must surface 'early=absent'; got: {rendered}",
    );
    assert!(
        !rendered.contains("max_age=999j"),
        "absent branch must NOT surface jiffies values; got: {rendered}",
    );
    assert!(
        !rendered.contains("jiffies not captured"),
        "absent branch must NOT surface the not-captured phrase; got: {rendered}",
    );
}

/// `early_skipped_reason` populated → Display surfaces the structured
/// reason directly in the header AND in the absent-branch body, replacing
/// the legacy "stall fired before half-way threshold, or runnable_at scan
/// setup failed" generic text. Pins the contract for the freeze
/// coordinator's three known reasons (scan prerequisites unavailable,
/// max_age never crossed threshold, scx_tick stall) and any future
/// addition.
#[test]
fn dual_dump_display_early_absent_renders_structured_reason() {
    let dual = DualFailureDumpReport {
        schema: SCHEMA_DUAL.to_string(),
        early: None,
        late: FailureDumpReport::default(),
        early_max_age_jiffies: 0,
        early_threshold_jiffies: 0,
        early_skipped_reason: Some("scx_tick stall — no per-task runnable_at data".to_string()),
    };
    let rendered = format!("{dual}");
    assert!(
        rendered.contains("scx_tick stall"),
        "structured reason must appear in absent header; got: {rendered}",
    );
    assert!(
        !rendered.contains("RUST_LOG=ktstr=debug"),
        "RUST_LOG hint must NOT appear when reason is structured; got: {rendered}",
    );
    assert!(
        !rendered.contains("stall fired before half-way threshold"),
        "legacy generic text must NOT appear when reason is structured; got: {rendered}",
    );
}

/// `early_skipped_reason` None on absent path → falls back to the
/// legacy two-cause generic text and the RUST_LOG hint. Preserves
/// rendering for older dump JSONs that predate
/// `early_skipped_reason` (the field deserialises as `None` on
/// missing-key inputs per `#[serde(default)]`).
#[test]
fn dual_dump_display_early_absent_falls_back_when_reason_absent() {
    let dual = DualFailureDumpReport {
        schema: SCHEMA_DUAL.to_string(),
        early: None,
        late: FailureDumpReport::default(),
        early_max_age_jiffies: 0,
        early_threshold_jiffies: 0,
        early_skipped_reason: None,
    };
    let rendered = format!("{dual}");
    assert!(
        rendered.contains("stall fired before half-way threshold"),
        "legacy generic text must appear when reason is absent; got: {rendered}",
    );
    assert!(
        rendered.contains("RUST_LOG=ktstr=debug"),
        "RUST_LOG hint must appear when reason is absent; got: {rendered}",
    );
}

// -- New per-section Display rendering ----------------------------
//
// Per-CPU CPU-time, per-node NUMA, and the scx_walker section each
// have wire-stable Display headers. Pin the formats so a renaming
// or a count-format swap surfaces in tests rather than as a
// mismatched log scrape.

/// `per_cpu_time` section: one-line summary of the CPU count
/// captured. Format: `per_cpu_time: {N} CPUs captured`.
#[test]
fn failure_dump_display_per_cpu_time_summary() {
    let report = FailureDumpReport {
        per_cpu_time: vec![
            super::PerCpuTimeStats {
                cpu: 0,
                ..super::PerCpuTimeStats::default()
            },
            super::PerCpuTimeStats {
                cpu: 1,
                ..super::PerCpuTimeStats::default()
            },
            super::PerCpuTimeStats {
                cpu: 2,
                ..super::PerCpuTimeStats::default()
            },
        ],
        ..FailureDumpReport::default()
    };
    let rendered = format!("{report}");
    assert!(
        rendered.contains("per_cpu_time: 3 CPUs captured"),
        "per_cpu_time section must surface CPU count; got: {rendered}",
    );
}

/// `per_cpu_time` empty + `per_node_numa` populated: the two
/// sections are independent — populated nodes render even when no
/// CPU rows were captured.
#[test]
fn failure_dump_display_per_node_numa_summary() {
    let report = FailureDumpReport {
        per_node_numa: vec![
            super::PerNodeNumaStats {
                node: 0,
                ..super::PerNodeNumaStats::default()
            },
            super::PerNodeNumaStats {
                node: 1,
                ..super::PerNodeNumaStats::default()
            },
        ],
        ..FailureDumpReport::default()
    };
    let rendered = format!("{report}");
    assert!(
        rendered.contains("per_node_numa: 2 nodes captured"),
        "per_node_numa section must surface node count; got: {rendered}",
    );
    assert!(
        !rendered.contains("per_cpu_time:"),
        "per_cpu_time must be elided when empty; got: {rendered}",
    );
}

/// `per_node_numa_unavailable` reason renders inline when the
/// walker bailed (current `"no NUMA walker"` placeholder until the
/// host-side walker lands).
#[test]
fn failure_dump_display_per_node_numa_unavailable() {
    let report = FailureDumpReport {
        per_node_numa_unavailable: Some("no NUMA walker".into()),
        ..FailureDumpReport::default()
    };
    let rendered = format!("{report}");
    assert!(
        rendered.contains("per_node_numa: <unavailable: no NUMA walker>"),
        "per_node_numa_unavailable must surface the reason inline; got: {rendered}",
    );
}

/// `scx_walker` section: one-line summary `rq_scx={N} dsq={M}
/// sched={captured|absent}`. Pinned because the format reads as a
/// log-scrapable triple a downstream tool can split on.
#[test]
fn failure_dump_display_scx_walker_all_present() {
    use crate::monitor::scx_walker::{DsqState, RqScxState, ScxSchedState};
    let report = FailureDumpReport {
        rq_scx_states: vec![RqScxState::default(); 4],
        dsq_states: vec![DsqState::default(); 2],
        scx_sched_state: Some(ScxSchedState::default()),
        ..FailureDumpReport::default()
    };
    let rendered = format!("{report}");
    assert!(
        rendered.contains("scx_walker: rq_scx=4 dsq=2 sched=captured"),
        "scx_walker present-everywhere must surface counts and 'captured'; got: {rendered}",
    );
}

/// `scx_walker` partial-output: rq_scx populated but no DSQs and no
/// scx_sched scalar. The section still renders (any non-empty
/// triggers the block) and `sched=absent` surfaces explicitly.
#[test]
fn failure_dump_display_scx_walker_partial() {
    use crate::monitor::scx_walker::RqScxState;
    let report = FailureDumpReport {
        rq_scx_states: vec![RqScxState::default()],
        ..FailureDumpReport::default()
    };
    let rendered = format!("{report}");
    assert!(
        rendered.contains("scx_walker: rq_scx=1 dsq=0 sched=absent"),
        "partial scx_walker must show 'sched=absent'; got: {rendered}",
    );
}

/// `scx_walker_unavailable` reason renders inline when the walker
/// could not run (e.g. scx_sched offsets unresolved).
#[test]
fn failure_dump_display_scx_walker_unavailable() {
    let report = FailureDumpReport {
        scx_walker_unavailable: Some("scx_sched offsets unresolved".into()),
        ..FailureDumpReport::default()
    };
    let rendered = format!("{report}");
    assert!(
        rendered.contains("scx_walker: <unavailable: scx_sched offsets unresolved>"),
        "scx_walker_unavailable must surface the reason inline; got: {rendered}",
    );
}

// -- Render-helper unit tests over synthetic guest memory --------
//
// Build a flat host-side buffer that simulates the guest direct
// mapping: every kernel KVA is `pa + page_offset`, and
// `translate_any_kva` in the production read path resolves through
// that mapping unchanged (no page-table walk needed). The synthetic
// `BpfMapOffsets` field offsets are arbitrary — they only need to be
// consistent with how the helpers read from the buffer.

/// Per-test synthetic-memory scene used by the render-helper tests.
/// Owns the guest buffer + the `BpfMapOffsets` block + the
/// `BpfRingbufOffsets` / `BpfStackmapOffsets` instances so a test
/// can borrow `&BpfMapOffsets` for a `GuestMemMapAccessor` without
/// each test re-stitching the offset substructs.
struct RenderScene {
    buf: Vec<u8>,
    page_offset: u64,
    /// `BpfMapOffsets` with `ringbuf_offsets` / `stackmap_offsets` /
    /// `array_value` populated so the synthetic helpers know where
    /// to find each field within the synthetic structs.
    offsets: crate::monitor::btf_offsets::BpfMapOffsets,
}

/// Direct-mapping KVA from a host PA: `kva = pa + page_offset`. The
/// production `translate_any_kva` reverses this with `kva - page_offset`
/// and bounds-checks against `mem.size()`.
fn pa_to_kva(pa: u64, page_offset: u64) -> u64 {
    page_offset.wrapping_add(pa)
}

/// Synthetic ringbuf offsets. The map carries `rb` at offset 0 of
/// `bpf_ringbuf_map` (the synthetic map struct is just the rb
/// pointer); the bpf_ringbuf struct lays out
/// mask/consumer_pos/producer_pos/pending_pos one per cacheline so
/// the production reader exercises the per-field translate.
fn synth_ringbuf_offsets() -> super::super::btf_offsets::BpfRingbufOffsets {
    super::super::btf_offsets::BpfRingbufOffsets {
        rbm_rb: 0,
        rb_mask: 0,
        rb_consumer_pos: 64,
        rb_producer_pos: 128,
        rb_pending_pos: 192,
    }
}

/// Build a `BpfMapOffsets` carrying only the ringbuf substruct.
/// Other walkers/render helpers do not consult these other fields,
/// so they stay zero-valued.
fn synth_ringbuf_map_offsets() -> crate::monitor::btf_offsets::BpfMapOffsets {
    let mut o = crate::monitor::btf_offsets::BpfMapOffsets::EMPTY;
    o.ringbuf_offsets = Some(synth_ringbuf_offsets());
    o
}

/// Lay out a ringbuf scene: bpf_ringbuf_map at PA 0x1000, bpf_ringbuf
/// at PA 0x10_0000. The map's `rb` pointer at offset 0 holds the
/// rb's KVA. The rb's four position fields hold the supplied values
/// at their respective offsets. Returns the scene plus the map's
/// KVA so the test can pass it as `info.map_kva`.
///
/// `rb_kva_override = Some(0)` writes a NULL rb pointer (exercises
/// the rb-NULL error path); `Some(other_kva)` writes an unmapped
/// pointer; `None` writes the rb's real KVA.
fn build_ringbuf_scene(
    mask: u64,
    consumer_pos: u64,
    producer_pos: u64,
    pending_pos: u64,
    rb_kva_override: Option<u64>,
) -> (RenderScene, u64) {
    let rb_offs = synth_ringbuf_offsets();
    let offsets = synth_ringbuf_map_offsets();
    let page_offset = crate::monitor::symbols::DEFAULT_PAGE_OFFSET;

    let map_pa: u64 = 0x1000;
    let rb_pa: u64 = 0x10_0000;
    let buf_size: usize = (rb_pa as usize) + 0x1000;

    let mut buf = vec![0u8; buf_size];

    let write_u64 = |buf: &mut Vec<u8>, pa: u64, val: u64| {
        let off = pa as usize;
        buf[off..off + 8].copy_from_slice(&val.to_ne_bytes());
    };

    // bpf_ringbuf_map.rb: pointer to bpf_ringbuf.
    let rb_kva = rb_kva_override.unwrap_or_else(|| pa_to_kva(rb_pa, page_offset));
    write_u64(&mut buf, map_pa + rb_offs.rbm_rb as u64, rb_kva);

    // bpf_ringbuf fields.
    write_u64(&mut buf, rb_pa + rb_offs.rb_mask as u64, mask);
    write_u64(
        &mut buf,
        rb_pa + rb_offs.rb_consumer_pos as u64,
        consumer_pos,
    );
    write_u64(
        &mut buf,
        rb_pa + rb_offs.rb_producer_pos as u64,
        producer_pos,
    );
    write_u64(&mut buf, rb_pa + rb_offs.rb_pending_pos as u64, pending_pos);

    let map_kva = pa_to_kva(map_pa, page_offset);
    (
        RenderScene {
            buf,
            page_offset,
            offsets,
        },
        map_kva,
    )
}

/// Build a `BpfMapInfo` for ringbuf tests with the given map_kva.
fn ringbuf_map_info(map_kva: u64) -> super::super::bpf_map::BpfMapInfo {
    let (name_bytes, name_len) = name_from_str("test_ringbuf");
    super::super::bpf_map::BpfMapInfo {
        map_pa: 0,
        map_kva,
        name_bytes,
        name_len,
        map_type: super::super::bpf_map::BPF_MAP_TYPE_RINGBUF,
        map_flags: 0,
        key_size: 0,
        value_size: 0,
        max_entries: 0,
        value_kva: None,
        btf_kva: 0,
        btf_value_type_id: 0,
        btf_vmlinux_value_type_id: 0,
        btf_key_type_id: 0,
    }
}

// -- ringbuf state render tests -------------------------------

/// `render_ringbuf_state` returns the no-offsets error string when
/// `BpfMapOffsets::ringbuf_offsets` is None (kernel built without
/// ringbuf, or BTF stripped).
#[test]
fn render_ringbuf_no_offsets_returns_err() {
    let (scene, map_kva) = build_ringbuf_scene(0xFFFF, 0x100, 0x200, 0x180, None);
    let info = ringbuf_map_info(map_kva);
    // SAFETY: scene.buf is a live local Vec<u8> whose backing
    // storage outlives the GuestMem use.
    let mem = unsafe {
        super::super::reader::GuestMem::new(scene.buf.as_ptr() as *mut u8, scene.buf.len() as u64)
    };
    let kernel = super::super::guest::GuestKernel::new_for_test(
        std::sync::Arc::new(mem),
        std::collections::HashMap::new(),
        scene.page_offset,
        0,
        false,
    );
    let kernel_ref = unsafe { &*(&kernel as *const _) };

    let mut offsets = scene.offsets;
    offsets.ringbuf_offsets = None;
    let accessor =
        super::super::bpf_map::GuestMemMapAccessor::new_for_test(kernel_ref, &offsets, 0);
    let result = render_ringbuf_state(&accessor, &info);
    assert!(matches!(result, Err(ref s) if s.contains("BTF lacks bpf_ringbuf_map")));
}

/// `render_ringbuf_state` returns the unmapped-map_kva error when
/// `info.map_kva` falls outside the synthetic memory window.
#[test]
fn render_ringbuf_unmapped_map_kva_returns_err() {
    let (scene, _map_kva) = build_ringbuf_scene(0xFFFF, 0x100, 0x200, 0x180, None);
    // Use a KVA that translates outside the buf.
    let bogus_map_kva = scene.page_offset + 0x100_0000;
    let info = ringbuf_map_info(bogus_map_kva);
    let mem = unsafe {
        super::super::reader::GuestMem::new(scene.buf.as_ptr() as *mut u8, scene.buf.len() as u64)
    };
    let kernel = super::super::guest::GuestKernel::new_for_test(
        std::sync::Arc::new(mem),
        std::collections::HashMap::new(),
        scene.page_offset,
        0,
        false,
    );
    let kernel_ref = unsafe { &*(&kernel as *const _) };
    let accessor =
        super::super::bpf_map::GuestMemMapAccessor::new_for_test(kernel_ref, &scene.offsets, 0);
    let result = render_ringbuf_state(&accessor, &info);
    assert!(matches!(result, Err(ref s) if s.contains("RINGBUF map_kva unmapped")));
}

/// `render_ringbuf_state` returns the rb-pointer-NULL error when
/// the bpf_ringbuf_map.rb field reads as 0.
#[test]
fn render_ringbuf_null_rb_returns_err() {
    let (scene, map_kva) = build_ringbuf_scene(0xFFFF, 0x100, 0x200, 0x180, Some(0));
    let info = ringbuf_map_info(map_kva);
    let mem = unsafe {
        super::super::reader::GuestMem::new(scene.buf.as_ptr() as *mut u8, scene.buf.len() as u64)
    };
    let kernel = super::super::guest::GuestKernel::new_for_test(
        std::sync::Arc::new(mem),
        std::collections::HashMap::new(),
        scene.page_offset,
        0,
        false,
    );
    let kernel_ref = unsafe { &*(&kernel as *const _) };
    let accessor =
        super::super::bpf_map::GuestMemMapAccessor::new_for_test(kernel_ref, &scene.offsets, 0);
    let result = render_ringbuf_state(&accessor, &info);
    assert!(matches!(result, Err(ref s) if s.contains("rb pointer NULL")));
}

/// `render_ringbuf_state` returns the rb-fields-unmapped error when
/// the bpf_ringbuf pointer translates outside the buffer.
#[test]
fn render_ringbuf_unmapped_rb_returns_err() {
    let (scene, map_kva) = build_ringbuf_scene(
        0xFFFF,
        0x100,
        0x200,
        0x180,
        Some(crate::monitor::symbols::DEFAULT_PAGE_OFFSET + 0x100_0000),
    );
    let info = ringbuf_map_info(map_kva);
    let mem = unsafe {
        super::super::reader::GuestMem::new(scene.buf.as_ptr() as *mut u8, scene.buf.len() as u64)
    };
    let kernel = super::super::guest::GuestKernel::new_for_test(
        std::sync::Arc::new(mem),
        std::collections::HashMap::new(),
        scene.page_offset,
        0,
        false,
    );
    let kernel_ref = unsafe { &*(&kernel as *const _) };
    let accessor =
        super::super::bpf_map::GuestMemMapAccessor::new_for_test(kernel_ref, &scene.offsets, 0);
    let result = render_ringbuf_state(&accessor, &info);
    assert!(matches!(result, Err(ref s) if s.contains("rb->mask unmapped")));
}

/// Happy path: capacity = mask + 1, pending_bytes = producer - consumer.
#[test]
fn render_ringbuf_basic_capacity_and_pending() {
    // 64 KiB ring (mask = 0xFFFF, capacity = 0x10000), consumer at
    // 0x100, producer at 0x300, pending = 0x200.
    let (scene, map_kva) = build_ringbuf_scene(0xFFFF, 0x100, 0x300, 0x180, None);
    let info = ringbuf_map_info(map_kva);
    let mem = unsafe {
        super::super::reader::GuestMem::new(scene.buf.as_ptr() as *mut u8, scene.buf.len() as u64)
    };
    let kernel = super::super::guest::GuestKernel::new_for_test(
        std::sync::Arc::new(mem),
        std::collections::HashMap::new(),
        scene.page_offset,
        0,
        false,
    );
    let kernel_ref = unsafe { &*(&kernel as *const _) };
    let accessor =
        super::super::bpf_map::GuestMemMapAccessor::new_for_test(kernel_ref, &scene.offsets, 0);
    let rb = render_ringbuf_state(&accessor, &info).expect("happy-path render");
    assert_eq!(rb.capacity, 0x10000);
    assert_eq!(rb.consumer_pos, 0x100);
    assert_eq!(rb.producer_pos, 0x300);
    assert_eq!(rb.pending_pos, 0x180);
    assert_eq!(rb.pending_bytes, 0x200);
}

/// Wraparound: producer < consumer in absolute terms => unsigned
/// wraparound subtraction yields a meaningful pending count.
#[test]
fn render_ringbuf_wraparound_pending_bytes() {
    // consumer beyond producer by 100; wrap subtraction yields
    // u64::MAX - 99.
    let consumer = 200u64;
    let producer = 100u64;
    let (scene, map_kva) = build_ringbuf_scene(0xFFFF, consumer, producer, producer, None);
    let info = ringbuf_map_info(map_kva);
    let mem = unsafe {
        super::super::reader::GuestMem::new(scene.buf.as_ptr() as *mut u8, scene.buf.len() as u64)
    };
    let kernel = super::super::guest::GuestKernel::new_for_test(
        std::sync::Arc::new(mem),
        std::collections::HashMap::new(),
        scene.page_offset,
        0,
        false,
    );
    let kernel_ref = unsafe { &*(&kernel as *const _) };
    let accessor =
        super::super::bpf_map::GuestMemMapAccessor::new_for_test(kernel_ref, &scene.offsets, 0);
    let rb = render_ringbuf_state(&accessor, &info).expect("wraparound render");
    assert_eq!(
        rb.pending_bytes,
        producer.wrapping_sub(consumer),
        "wraparound subtraction must match production semantics",
    );
}

/// `mask = u64::MAX` triggers the "capacity would wrap to 0" guard;
/// the helper bails with an explicit error rather than producing a
/// nonsense capacity = 0.
#[test]
fn render_ringbuf_mask_max_returns_err() {
    let (scene, map_kva) = build_ringbuf_scene(u64::MAX, 0, 0, 0, None);
    let info = ringbuf_map_info(map_kva);
    let mem = unsafe {
        super::super::reader::GuestMem::new(scene.buf.as_ptr() as *mut u8, scene.buf.len() as u64)
    };
    let kernel = super::super::guest::GuestKernel::new_for_test(
        std::sync::Arc::new(mem),
        std::collections::HashMap::new(),
        scene.page_offset,
        0,
        false,
    );
    let kernel_ref = unsafe { &*(&kernel as *const _) };
    let accessor =
        super::super::bpf_map::GuestMemMapAccessor::new_for_test(kernel_ref, &scene.offsets, 0);
    let result = render_ringbuf_state(&accessor, &info);
    assert!(matches!(result, Err(ref s) if s.contains("mask = u64::MAX")));
}

// -- stack-trace render tests ---------------------------------

fn synth_stackmap_offsets() -> super::super::btf_offsets::BpfStackmapOffsets {
    super::super::btf_offsets::BpfStackmapOffsets {
        smap_n_buckets: 0,
        smap_buckets: 16,
        smb_nr: 0,
        smb_data: 16,
    }
}

fn synth_stackmap_map_offsets() -> crate::monitor::btf_offsets::BpfMapOffsets {
    let mut o = crate::monitor::btf_offsets::BpfMapOffsets::EMPTY;
    o.stackmap_offsets = Some(synth_stackmap_offsets());
    o
}

/// Build a stack-trace scene. `bucket_pc_lists[i]` carries the PCs
/// for bucket `i` (empty Vec means an empty/null bucket pointer);
/// the layout writes a `bpf_stack_map` with `n_buckets` and
/// `buckets[]` flex array, plus per-bucket `stack_map_bucket`
/// structs containing `nr` + `data[]`.
fn build_stackmap_scene(
    bucket_pc_lists: &[Vec<u64>],
    map_flags: u32,
) -> (RenderScene, super::super::bpf_map::BpfMapInfo) {
    let sm_offs = synth_stackmap_offsets();
    let offsets = synth_stackmap_map_offsets();
    let page_offset = crate::monitor::symbols::DEFAULT_PAGE_OFFSET;

    // bpf_stack_map at PA 0x1000. Layout:
    //   [0..4) n_buckets (u32)
    //   [16..16 + n*8) buckets[] (each entry is u64 pointer)
    let map_pa: u64 = 0x1000;
    let n_buckets = bucket_pc_lists.len() as u32;
    let map_struct_end = sm_offs.smap_buckets as u64 + (n_buckets as u64) * 8;

    // Each populated bucket gets a stack_map_bucket at fixed strides
    // starting 0x1_0000, each 0x1000 apart.
    let bucket_stride: u64 = 0x1000;
    let buckets_start: u64 = 0x1_0000;
    let buf_size: usize = (buckets_start + bucket_stride * (n_buckets as u64 + 1)) as usize;
    let mut buf = vec![0u8; buf_size];

    let write_u32 = |buf: &mut Vec<u8>, pa: u64, val: u32| {
        let off = pa as usize;
        buf[off..off + 4].copy_from_slice(&val.to_ne_bytes());
    };
    let write_u64 = |buf: &mut Vec<u8>, pa: u64, val: u64| {
        let off = pa as usize;
        buf[off..off + 8].copy_from_slice(&val.to_ne_bytes());
    };

    // n_buckets at smap_n_buckets (offset 0).
    write_u32(&mut buf, map_pa + sm_offs.smap_n_buckets as u64, n_buckets);

    // Per-bucket pointers and bucket data.
    let _ = map_struct_end; // silence unused
    for (i, pcs) in bucket_pc_lists.iter().enumerate() {
        let slot_pa = map_pa + sm_offs.smap_buckets as u64 + (i as u64) * 8;
        if pcs.is_empty() {
            write_u64(&mut buf, slot_pa, 0); // null bucket pointer
            continue;
        }
        let bucket_pa = buckets_start + (i as u64) * bucket_stride;
        write_u64(&mut buf, slot_pa, pa_to_kva(bucket_pa, page_offset));
        // stack_map_bucket: nr at smb_nr (0), data[] at smb_data (16).
        write_u32(
            &mut buf,
            bucket_pa + sm_offs.smb_nr as u64,
            pcs.len() as u32,
        );
        for (j, pc) in pcs.iter().enumerate() {
            write_u64(
                &mut buf,
                bucket_pa + sm_offs.smb_data as u64 + (j as u64) * 8,
                *pc,
            );
        }
    }

    let map_kva = pa_to_kva(map_pa, page_offset);
    let (name_bytes, name_len) = name_from_str("test_stack");
    let info = super::super::bpf_map::BpfMapInfo {
        map_pa: 0,
        map_kva,
        name_bytes,
        name_len,
        map_type: super::super::bpf_map::BPF_MAP_TYPE_STACK_TRACE,
        map_flags,
        key_size: 0,
        value_size: 0,
        max_entries: n_buckets,
        value_kva: None,
        btf_kva: 0,
        btf_value_type_id: 0,
        btf_vmlinux_value_type_id: 0,
        btf_key_type_id: 0,
    };

    (
        RenderScene {
            buf,
            page_offset,
            offsets,
        },
        info,
    )
}

/// `render_stack_traces` returns the BTF-lacks error when
/// stackmap_offsets is None.
#[test]
fn render_stack_traces_no_offsets_returns_err() {
    let (mut scene, info) = build_stackmap_scene(&[vec![]], 0);
    scene.offsets.stackmap_offsets = None;
    // SAFETY: scene.buf is a live local Vec<u8> whose backing
    // storage outlives the GuestMem use.
    let mem = unsafe {
        super::super::reader::GuestMem::new(scene.buf.as_ptr() as *mut u8, scene.buf.len() as u64)
    };
    let kernel = super::super::guest::GuestKernel::new_for_test(
        std::sync::Arc::new(mem),
        std::collections::HashMap::new(),
        scene.page_offset,
        0,
        false,
    );
    let kernel_ref = unsafe { &*(&kernel as *const _) };
    let accessor =
        super::super::bpf_map::GuestMemMapAccessor::new_for_test(kernel_ref, &scene.offsets, 0);
    let result = render_stack_traces(&accessor, &info);
    assert!(matches!(result, Err(ref s) if s.contains("BTF lacks bpf_stack_map")));
}

/// `render_stack_traces` returns the unmapped-map_kva error when
/// info.map_kva translates outside the buffer.
#[test]
fn render_stack_traces_unmapped_map_kva_returns_err() {
    let (scene, _info) = build_stackmap_scene(&[vec![]], 0);
    let mut info = _info;
    info.map_kva = scene.page_offset + 0x100_0000;
    let mem = unsafe {
        super::super::reader::GuestMem::new(scene.buf.as_ptr() as *mut u8, scene.buf.len() as u64)
    };
    let kernel = super::super::guest::GuestKernel::new_for_test(
        std::sync::Arc::new(mem),
        std::collections::HashMap::new(),
        scene.page_offset,
        0,
        false,
    );
    let kernel_ref = unsafe { &*(&kernel as *const _) };
    let accessor =
        super::super::bpf_map::GuestMemMapAccessor::new_for_test(kernel_ref, &scene.offsets, 0);
    let result = render_stack_traces(&accessor, &info);
    assert!(matches!(result, Err(ref s) if s.contains("STACK_TRACE map_kva unmapped")));
}

/// All-empty buckets: walker returns Ok with `n_buckets` set, an
/// empty entries vec, and `truncated=false`.
#[test]
fn render_stack_traces_empty_returns_no_entries() {
    let (scene, info) = build_stackmap_scene(&[vec![], vec![], vec![], vec![]], 0);
    let mem = unsafe {
        super::super::reader::GuestMem::new(scene.buf.as_ptr() as *mut u8, scene.buf.len() as u64)
    };
    let kernel = super::super::guest::GuestKernel::new_for_test(
        std::sync::Arc::new(mem),
        std::collections::HashMap::new(),
        scene.page_offset,
        0,
        false,
    );
    let kernel_ref = unsafe { &*(&kernel as *const _) };
    let accessor =
        super::super::bpf_map::GuestMemMapAccessor::new_for_test(kernel_ref, &scene.offsets, 0);
    let st = render_stack_traces(&accessor, &info).expect("empty render");
    assert_eq!(st.n_buckets, 4);
    assert!(st.entries.is_empty());
    assert!(!st.truncated);
}

/// Populated buckets surface their PCs in `entries[].pcs`.
#[test]
fn render_stack_traces_populated_pcs() {
    let (scene, info) = build_stackmap_scene(
        &[
            vec![],
            vec![0xFFFF_FFFF_8100_1000, 0xFFFF_FFFF_8100_2000],
            vec![],
            vec![0xFFFF_FFFF_8200_3000],
        ],
        0,
    );
    let mem = unsafe {
        super::super::reader::GuestMem::new(scene.buf.as_ptr() as *mut u8, scene.buf.len() as u64)
    };
    let kernel = super::super::guest::GuestKernel::new_for_test(
        std::sync::Arc::new(mem),
        std::collections::HashMap::new(),
        scene.page_offset,
        0,
        false,
    );
    let kernel_ref = unsafe { &*(&kernel as *const _) };
    let accessor =
        super::super::bpf_map::GuestMemMapAccessor::new_for_test(kernel_ref, &scene.offsets, 0);
    let st = render_stack_traces(&accessor, &info).expect("populated render");
    assert_eq!(st.n_buckets, 4);
    assert_eq!(st.entries.len(), 2);
    assert_eq!(st.entries[0].bucket_id, 1);
    assert_eq!(st.entries[0].nr, 2);
    assert_eq!(
        st.entries[0].pcs,
        vec![0xFFFF_FFFF_8100_1000, 0xFFFF_FFFF_8100_2000]
    );
    assert_eq!(st.entries[1].bucket_id, 3);
    assert_eq!(st.entries[1].pcs, vec![0xFFFF_FFFF_8200_3000]);
    assert!(!st.truncated);
}

/// Build-id mode: pcs vector stays empty (per-entry shape is
/// bpf_stack_build_id, not u64), data_hex carries raw bytes.
#[test]
fn render_stack_traces_build_id_mode_pcs_empty() {
    const BPF_F_STACK_BUILD_ID: u32 = 1 << 5;
    // One bucket with one "PC slot" (treated as 8 bytes of raw data
    // in build-id mode). The kernel's per-entry size is 32 bytes
    // (bpf_stack_build_id); our synthetic test only needs to verify
    // pcs stays empty regardless of data shape.
    let (scene, info) = build_stackmap_scene(&[vec![0xDEAD_BEEFu64]], BPF_F_STACK_BUILD_ID);
    let mem = unsafe {
        super::super::reader::GuestMem::new(scene.buf.as_ptr() as *mut u8, scene.buf.len() as u64)
    };
    let kernel = super::super::guest::GuestKernel::new_for_test(
        std::sync::Arc::new(mem),
        std::collections::HashMap::new(),
        scene.page_offset,
        0,
        false,
    );
    let kernel_ref = unsafe { &*(&kernel as *const _) };
    let accessor =
        super::super::bpf_map::GuestMemMapAccessor::new_for_test(kernel_ref, &scene.offsets, 0);
    let st = render_stack_traces(&accessor, &info).expect("build-id render");
    assert_eq!(st.entries.len(), 1);
    assert!(
        st.entries[0].pcs.is_empty(),
        "build-id mode must NOT populate pcs (entry shape is bpf_stack_build_id, not u64)"
    );
}

// -- fd-array render tests ------------------------------------

fn synth_fd_array_offsets() -> crate::monitor::btf_offsets::BpfMapOffsets {
    let mut o = crate::monitor::btf_offsets::BpfMapOffsets::EMPTY;
    // Place ptrs[] at offset 16 within the synthetic bpf_array.
    o.array_value = 16;
    o
}

/// Build a synthetic FD-array scene. `populated_indices` lists the
/// slot indices that should be non-zero. `max_entries` controls the
/// scan upper bound.
fn build_fd_array_scene(
    map_type: u32,
    max_entries: u32,
    populated_indices: &[u32],
) -> (RenderScene, super::super::bpf_map::BpfMapInfo) {
    let offsets = synth_fd_array_offsets();
    let page_offset = crate::monitor::symbols::DEFAULT_PAGE_OFFSET;

    // bpf_array at PA 0x1000. ptrs[] starts at offset
    // `array_value` (16); each slot is 8 bytes.
    let map_pa: u64 = 0x1000;
    let scan = max_entries.min(super::render_map::MAX_FD_ARRAY_SLOTS);
    let buf_size =
        (map_pa as usize) + (offsets.array_value as usize) + (scan as usize) * 8 + 0x1000;
    let mut buf = vec![0u8; buf_size];
    let write_u64 = |buf: &mut Vec<u8>, pa: u64, val: u64| {
        let off = pa as usize;
        buf[off..off + 8].copy_from_slice(&val.to_ne_bytes());
    };

    for &idx in populated_indices {
        if idx >= scan {
            continue;
        }
        let slot_pa = map_pa + offsets.array_value as u64 + (idx as u64) * 8;
        // Any non-zero pointer suffices; use the slot index + 1
        // shifted into a recognizable kernel-pointer range.
        write_u64(&mut buf, slot_pa, 0xFFFF_8000_0000_0000 + (idx as u64));
    }

    let map_kva = pa_to_kva(map_pa, page_offset);
    let (name_bytes, name_len) = name_from_str("test_fd_array");
    let info = super::super::bpf_map::BpfMapInfo {
        map_pa: 0,
        map_kva,
        name_bytes,
        name_len,
        map_type,
        map_flags: 0,
        key_size: 0,
        value_size: 0,
        max_entries,
        value_kva: None,
        btf_kva: 0,
        btf_value_type_id: 0,
        btf_vmlinux_value_type_id: 0,
        btf_key_type_id: 0,
    };
    (
        RenderScene {
            buf,
            page_offset,
            offsets,
        },
        info,
    )
}

/// PROG_ARRAY with three populated slots: walker reports
/// populated=3, indices=[indexes].
#[test]
fn render_fd_array_populated_indices() {
    let (scene, info) = build_fd_array_scene(
        super::super::bpf_map::BPF_MAP_TYPE_PROG_ARRAY,
        16,
        &[0, 5, 10],
    );
    let mem = unsafe {
        super::super::reader::GuestMem::new(scene.buf.as_ptr() as *mut u8, scene.buf.len() as u64)
    };
    let kernel = super::super::guest::GuestKernel::new_for_test(
        std::sync::Arc::new(mem),
        std::collections::HashMap::new(),
        scene.page_offset,
        0,
        false,
    );
    let kernel_ref = unsafe { &*(&kernel as *const _) };
    let accessor =
        super::super::bpf_map::GuestMemMapAccessor::new_for_test(kernel_ref, &scene.offsets, 0);
    let fa = render_fd_array_slots(&accessor, &info);
    assert_eq!(fa.populated, 3);
    assert_eq!(fa.scanned, 16);
    assert_eq!(fa.indices, vec![0, 5, 10]);
    assert!(!fa.truncated);
    assert!(
        !fa.indices_truncated,
        "populated == indices.len() must NOT set indices_truncated",
    );
}

/// `fd_map_is_hash_shaped` flags exactly the three hash-table-shaped
/// FD-map types and nothing else.
#[test]
fn fd_map_is_hash_shaped_flags_only_hash_families() {
    use super::super::bpf_map::{
        BPF_MAP_TYPE_ARRAY_OF_MAPS, BPF_MAP_TYPE_DEVMAP, BPF_MAP_TYPE_DEVMAP_HASH,
        BPF_MAP_TYPE_HASH_OF_MAPS, BPF_MAP_TYPE_PROG_ARRAY, BPF_MAP_TYPE_SOCKHASH,
        BPF_MAP_TYPE_SOCKMAP,
    };
    use super::render_map::fd_map_is_hash_shaped;
    assert!(fd_map_is_hash_shaped(BPF_MAP_TYPE_SOCKHASH));
    assert!(fd_map_is_hash_shaped(BPF_MAP_TYPE_DEVMAP_HASH));
    assert!(fd_map_is_hash_shaped(BPF_MAP_TYPE_HASH_OF_MAPS));
    // Array-shaped FD families are not hash-shaped.
    assert!(!fd_map_is_hash_shaped(BPF_MAP_TYPE_PROG_ARRAY));
    assert!(!fd_map_is_hash_shaped(BPF_MAP_TYPE_SOCKMAP));
    assert!(!fd_map_is_hash_shaped(BPF_MAP_TYPE_DEVMAP));
    assert!(!fd_map_is_hash_shaped(BPF_MAP_TYPE_ARRAY_OF_MAPS));
}

/// `render_map` surfaces the unwalkable-gap `error` for a hash-shaped
/// FD map (SOCKHASH) so an operator can tell "this dump path can't walk
/// hash-shaped FD maps" apart from "genuinely empty array map" — both
/// otherwise render `fd_array.populated == 0`. The direct-walker bail
/// (`scanned == 0`) is covered by `render_fd_array_hash_shaped_returns_empty`.
#[test]
fn render_map_hash_shaped_fd_map_sets_error() {
    let page_offset = crate::monitor::symbols::DEFAULT_PAGE_OFFSET;
    let buf = vec![0u8; 0x4000];
    // SAFETY: buf is a live local Vec<u8> outliving the GuestMem use.
    let mem =
        unsafe { super::super::reader::GuestMem::new(buf.as_ptr() as *mut u8, buf.len() as u64) };
    let kernel = super::super::guest::GuestKernel::new_for_test(
        std::sync::Arc::new(mem),
        std::collections::HashMap::new(),
        page_offset,
        0,
        false,
    );
    let kernel_ref = unsafe { &*(&kernel as *const _) };
    let offsets = crate::monitor::btf_offsets::BpfMapOffsets::EMPTY;
    let accessor =
        super::super::bpf_map::GuestMemMapAccessor::new_for_test(kernel_ref, &offsets, 0);
    let (name_bytes, name_len) = name_from_str("test_sockhash");
    let info = super::super::bpf_map::BpfMapInfo {
        map_pa: 0,
        map_kva: pa_to_kva(0x1000, page_offset),
        name_bytes,
        name_len,
        map_type: super::super::bpf_map::BPF_MAP_TYPE_SOCKHASH,
        map_flags: 0,
        key_size: 4,
        value_size: 4,
        // Large max_entries: a scanning walker would report on these;
        // the bail must ignore the count entirely.
        max_entries: 1024,
        value_kva: None,
        btf_kva: 0,
        btf_value_type_id: 0,
        btf_vmlinux_value_type_id: 0,
        btf_key_type_id: 0,
    };
    let arena_page_index = super::render_map::ArenaPageIndex::new();
    let sdt_alloc_metas: Vec<super::render_map::SdtAllocMeta> = Vec::new();
    let ctx = super::render_map::RenderMapCtx {
        accessor: &accessor,
        btf: None,
        num_cpus: 1,
        arena_offsets: None,
        shared_arena: None,
        arena_page_index: &arena_page_index,
        sdt_alloc_metas: &sdt_alloc_metas,
        cast_map: None,
        arena_slot_index: None,
        cross_btf_fwd_index: None,
        scx_static_index: None,
        alloc_size_types: &[],
        rendered_slot_addrs: None,
    };
    let rendered = super::render_map::render_map(&ctx, &info);
    let err = rendered
        .error
        .expect("hash-shaped FD map must surface the unwalkable-gap error");
    assert!(
        err.contains("hash-shaped FD maps"),
        "error must name the limitation; got: {err}",
    );
    let fd = rendered
        .fd_array
        .expect("fd_array slot is still populated (with scanned: 0)");
    assert_eq!(fd.scanned, 0);
    assert_eq!(fd.populated, 0);
}

/// HASH-shaped FD families (SOCKHASH / DEVMAP_HASH / HASH_OF_MAPS)
/// short-circuit to populated=0/scanned=0/empty.
#[test]
fn render_fd_array_hash_shaped_returns_empty() {
    let (scene, info) = build_fd_array_scene(
        super::super::bpf_map::BPF_MAP_TYPE_SOCKHASH,
        16,
        &[0, 5, 10],
    );
    let mem = unsafe {
        super::super::reader::GuestMem::new(scene.buf.as_ptr() as *mut u8, scene.buf.len() as u64)
    };
    let kernel = super::super::guest::GuestKernel::new_for_test(
        std::sync::Arc::new(mem),
        std::collections::HashMap::new(),
        scene.page_offset,
        0,
        false,
    );
    let kernel_ref = unsafe { &*(&kernel as *const _) };
    let accessor =
        super::super::bpf_map::GuestMemMapAccessor::new_for_test(kernel_ref, &scene.offsets, 0);
    let fa = render_fd_array_slots(&accessor, &info);
    assert_eq!(fa.populated, 0);
    assert_eq!(fa.scanned, 0);
    assert!(fa.indices.is_empty());
    assert!(!fa.truncated);
    assert!(
        !fa.indices_truncated,
        "hash-shaped early exit must NOT set indices_truncated",
    );
}

/// `max_entries > MAX_FD_ARRAY_SLOTS` triggers the truncation flag.
/// Walker still reports populated/indices for the slots it scanned.
#[test]
fn render_fd_array_max_entries_truncation() {
    // max_entries one above the cap. Skip populating slots — the
    // truncation flag fires regardless of population state. Build a
    // scene with the cap as effective scan size.
    let (scene, mut info) = build_fd_array_scene(
        super::super::bpf_map::BPF_MAP_TYPE_PROG_ARRAY,
        super::render_map::MAX_FD_ARRAY_SLOTS,
        &[],
    );
    info.max_entries = super::render_map::MAX_FD_ARRAY_SLOTS + 1;
    let mem = unsafe {
        super::super::reader::GuestMem::new(scene.buf.as_ptr() as *mut u8, scene.buf.len() as u64)
    };
    let kernel = super::super::guest::GuestKernel::new_for_test(
        std::sync::Arc::new(mem),
        std::collections::HashMap::new(),
        scene.page_offset,
        0,
        false,
    );
    let kernel_ref = unsafe { &*(&kernel as *const _) };
    let accessor =
        super::super::bpf_map::GuestMemMapAccessor::new_for_test(kernel_ref, &scene.offsets, 0);
    let fa = render_fd_array_slots(&accessor, &info);
    assert!(
        fa.truncated,
        "max_entries above MAX_FD_ARRAY_SLOTS must set truncated"
    );
    assert_eq!(fa.scanned, super::render_map::MAX_FD_ARRAY_SLOTS);
    // No populated slots in this scene → indices.len() == 0 == populated,
    // so indices_truncated stays false even though scan-size truncation
    // fires. Pin the orthogonality of the two flags.
    assert!(
        !fa.indices_truncated,
        "scan-size truncation must NOT set indices_truncated when populated == indices.len()",
    );
    let _ = scene; // silence unused
}

// -- STRUCT_OPS render error paths ----------------------------

/// `render_map` STRUCT_OPS arm with `struct_ops_offsets = None`
/// surfaces the BTF-offsets-unresolved diagnostic — the renderer
/// must NOT silently read with `data_off = 0` against a wrapper-
/// inclusive `value_size`.
#[test]
fn render_map_struct_ops_no_offsets_returns_error() {
    let mut offsets = crate::monitor::btf_offsets::BpfMapOffsets::EMPTY;
    // No struct_ops_offsets resolution.
    offsets.struct_ops_offsets = None;
    // Provide buf with map at PA 0x1000 — won't actually be read.
    let buf = vec![0u8; 0x4000];
    let page_offset = crate::monitor::symbols::DEFAULT_PAGE_OFFSET;
    // SAFETY: buf is a live local Vec<u8>.
    let mem =
        unsafe { super::super::reader::GuestMem::new(buf.as_ptr() as *mut u8, buf.len() as u64) };
    let kernel = super::super::guest::GuestKernel::new_for_test(
        std::sync::Arc::new(mem),
        std::collections::HashMap::new(),
        page_offset,
        0,
        false,
    );
    let kernel_ref = unsafe { &*(&kernel as *const _) };
    let accessor =
        super::super::bpf_map::GuestMemMapAccessor::new_for_test(kernel_ref, &offsets, 0);
    let (name_bytes, name_len) = name_from_str("test_struct_ops");
    let info = super::super::bpf_map::BpfMapInfo {
        map_pa: 0,
        map_kva: pa_to_kva(0x1000, page_offset),
        name_bytes,
        name_len,
        map_type: super::super::bpf_map::BPF_MAP_TYPE_STRUCT_OPS,
        map_flags: 0,
        key_size: 0,
        value_size: 256,
        max_entries: 1,
        value_kva: None,
        btf_kva: 0,
        btf_value_type_id: 0,
        btf_vmlinux_value_type_id: 0,
        btf_key_type_id: 0,
    };
    let arena_page_index = super::render_map::ArenaPageIndex::new();
    let sdt_alloc_metas: Vec<super::render_map::SdtAllocMeta> = Vec::new();
    let ctx = super::render_map::RenderMapCtx {
        accessor: &accessor,
        btf: None,
        num_cpus: 1,
        arena_offsets: None,
        shared_arena: None,
        arena_page_index: &arena_page_index,
        sdt_alloc_metas: &sdt_alloc_metas,
        cast_map: None,
        arena_slot_index: None,
        cross_btf_fwd_index: None,
        scx_static_index: None,
        alloc_size_types: &[],
        rendered_slot_addrs: None,
    };
    let rendered = super::render_map::render_map(&ctx, &info);
    let err = rendered
        .error
        .expect("STRUCT_OPS no-offsets must surface error");
    assert!(
        err.contains("STRUCT_OPS value unreadable") && err.contains("BTF offsets unresolved"),
        "STRUCT_OPS no-offsets error must explain the resolution failure; got: {err}",
    );
}

/// `render_map` STRUCT_OPS arm with valid struct_ops_offsets but
/// `value_kva` translating outside the buffer surfaces the
/// "value region unmapped" diagnostic.
#[test]
fn render_map_struct_ops_unmapped_value_returns_error() {
    let mut offsets = crate::monitor::btf_offsets::BpfMapOffsets::EMPTY;
    offsets.struct_ops_offsets = Some(super::super::btf_offsets::StructOpsOffsets {
        kvalue: 64,
        value_data: 8,
    });
    let buf = vec![0u8; 0x4000];
    let page_offset = crate::monitor::symbols::DEFAULT_PAGE_OFFSET;
    let mem =
        unsafe { super::super::reader::GuestMem::new(buf.as_ptr() as *mut u8, buf.len() as u64) };
    let kernel = super::super::guest::GuestKernel::new_for_test(
        std::sync::Arc::new(mem),
        std::collections::HashMap::new(),
        page_offset,
        0,
        false,
    );
    let kernel_ref = unsafe { &*(&kernel as *const _) };
    let accessor =
        super::super::bpf_map::GuestMemMapAccessor::new_for_test(kernel_ref, &offsets, 0);
    // value_kva points outside the buffer (page_offset + far) so
    // the read_value translate fails.
    let (name_bytes, name_len) = name_from_str("test_struct_ops");
    let info = super::super::bpf_map::BpfMapInfo {
        map_pa: 0,
        map_kva: pa_to_kva(0x1000, page_offset),
        name_bytes,
        name_len,
        map_type: super::super::bpf_map::BPF_MAP_TYPE_STRUCT_OPS,
        map_flags: 0,
        key_size: 0,
        value_size: 256,
        max_entries: 1,
        value_kva: Some(page_offset + 0x100_0000), // far past buffer
        btf_kva: 0,
        btf_value_type_id: 0,
        btf_vmlinux_value_type_id: 0,
        btf_key_type_id: 0,
    };
    let arena_page_index = super::render_map::ArenaPageIndex::new();
    let sdt_alloc_metas: Vec<super::render_map::SdtAllocMeta> = Vec::new();
    let ctx = super::render_map::RenderMapCtx {
        accessor: &accessor,
        btf: None,
        num_cpus: 1,
        arena_offsets: None,
        shared_arena: None,
        arena_page_index: &arena_page_index,
        sdt_alloc_metas: &sdt_alloc_metas,
        cast_map: None,
        arena_slot_index: None,
        cross_btf_fwd_index: None,
        scx_static_index: None,
        alloc_size_types: &[],
        rendered_slot_addrs: None,
    };
    let rendered = super::render_map::render_map(&ctx, &info);
    let err = rendered
        .error
        .expect("STRUCT_OPS unmapped-value must surface error");
    assert!(
        err.contains("STRUCT_OPS value unreadable") && err.contains("value region unmapped"),
        "STRUCT_OPS unmapped-value error must mention the unmapped region; got: {err}",
    );
}

/// A multi-entry plain ARRAY whose value pages are unmapped in this
/// synthetic guest (cr3 = 0, no page table) surfaces every key as
/// `value: None`, leaves the single-entry `value` unset, and reports
/// the unreadable count in `error` — the render arm never aborts on a
/// per-key read miss. Happy-path rendering (readable keys, correct
/// values) is covered by the real-VM e2e
/// (tests/failure_dump_e2e.rs::scenario_failure_dump_renders_array_entries).
#[test]
fn render_map_multi_entry_array_all_keys_unreadable() {
    let page_offset = crate::monitor::symbols::DEFAULT_PAGE_OFFSET;
    let buf = vec![0u8; 0x4000];
    // SAFETY: buf is a live local Vec<u8> outliving the GuestMem use.
    let mem =
        unsafe { super::super::reader::GuestMem::new(buf.as_ptr() as *mut u8, buf.len() as u64) };
    // cr3 = 0 → no page table, so every value-region read misses: the
    // synthetic guest has no mapping for value_kva. Drives the render
    // arm's per-key None path.
    let kernel = super::super::guest::GuestKernel::new_for_test(
        std::sync::Arc::new(mem),
        std::collections::HashMap::new(),
        page_offset,
        0,
        false,
    );
    let kernel_ref = unsafe { &*(&kernel as *const _) };
    let offsets = crate::monitor::btf_offsets::BpfMapOffsets::EMPTY;
    let accessor =
        super::super::bpf_map::GuestMemMapAccessor::new_for_test(kernel_ref, &offsets, 0);
    let (name_bytes, name_len) = name_from_str("test_cells");
    let info = super::super::bpf_map::BpfMapInfo {
        map_pa: 0,
        map_kva: pa_to_kva(0x1000, page_offset),
        name_bytes,
        name_len,
        map_type: super::super::bpf_map::BPF_MAP_TYPE_ARRAY,
        map_flags: 0,
        key_size: 4,
        value_size: 4,
        max_entries: 3,
        value_kva: Some(pa_to_kva(0x2000, page_offset)),
        btf_kva: 0,
        btf_value_type_id: 0,
        btf_vmlinux_value_type_id: 0,
        btf_key_type_id: 0,
    };
    let arena_page_index = super::render_map::ArenaPageIndex::new();
    let sdt_alloc_metas: Vec<super::render_map::SdtAllocMeta> = Vec::new();
    let ctx = super::render_map::RenderMapCtx {
        accessor: &accessor,
        btf: None,
        num_cpus: 1,
        arena_offsets: None,
        shared_arena: None,
        arena_page_index: &arena_page_index,
        sdt_alloc_metas: &sdt_alloc_metas,
        cast_map: None,
        arena_slot_index: None,
        cross_btf_fwd_index: None,
        scx_static_index: None,
        alloc_size_types: &[],
        rendered_slot_addrs: None,
    };
    let rendered = super::render_map::render_map(&ctx, &info);
    // Multi-entry path: a slot per key; single-entry `value` unset.
    assert!(
        rendered.value.is_none(),
        "multi-entry ARRAY must use array_entries, not single-entry `value`"
    );
    assert_eq!(
        rendered.array_entries.len(),
        3,
        "every key gets an entry slot even when unreadable"
    );
    assert_eq!(rendered.array_entries[0].key, 0);
    assert_eq!(rendered.array_entries[2].key, 2);
    assert!(
        rendered.array_entries.iter().all(|e| e.value.is_none()),
        "every key is unreadable in this synthetic guest"
    );
    let err = rendered
        .error
        .expect("all-unreadable render must surface an error");
    assert!(
        err.contains("3 keys unreadable"),
        "error must report the unreadable count; got: {err}"
    );
}

/// A multi-entry ARRAY declaring more keys than `MAX_ARRAY_KEYS`
/// renders only the first `MAX_ARRAY_KEYS` entries and records the cap
/// in `error`, mirroring the PERCPU_ARRAY truncation prose. (Reads
/// miss in this synthetic guest, so the unreadable count rides
/// alongside the truncation note, joined with "; ".)
#[test]
fn render_map_multi_entry_array_truncates_at_cap() {
    let page_offset = crate::monitor::symbols::DEFAULT_PAGE_OFFSET;
    let buf = vec![0u8; 0x4000];
    // SAFETY: buf is a live local Vec<u8> outliving the GuestMem use.
    let mem =
        unsafe { super::super::reader::GuestMem::new(buf.as_ptr() as *mut u8, buf.len() as u64) };
    let kernel = super::super::guest::GuestKernel::new_for_test(
        std::sync::Arc::new(mem),
        std::collections::HashMap::new(),
        page_offset,
        0,
        false,
    );
    let kernel_ref = unsafe { &*(&kernel as *const _) };
    let offsets = crate::monitor::btf_offsets::BpfMapOffsets::EMPTY;
    let accessor =
        super::super::bpf_map::GuestMemMapAccessor::new_for_test(kernel_ref, &offsets, 0);
    let (name_bytes, name_len) = name_from_str("test_cells");
    // Declare more keys than the cap so the render arm truncates.
    let max_entries: u32 = MAX_ARRAY_KEYS + 904;
    let info = super::super::bpf_map::BpfMapInfo {
        map_pa: 0,
        map_kva: pa_to_kva(0x1000, page_offset),
        name_bytes,
        name_len,
        map_type: super::super::bpf_map::BPF_MAP_TYPE_ARRAY,
        map_flags: 0,
        key_size: 4,
        value_size: 4,
        max_entries,
        value_kva: Some(pa_to_kva(0x2000, page_offset)),
        btf_kva: 0,
        btf_value_type_id: 0,
        btf_vmlinux_value_type_id: 0,
        btf_key_type_id: 0,
    };
    let arena_page_index = super::render_map::ArenaPageIndex::new();
    let sdt_alloc_metas: Vec<super::render_map::SdtAllocMeta> = Vec::new();
    let ctx = super::render_map::RenderMapCtx {
        accessor: &accessor,
        btf: None,
        num_cpus: 1,
        arena_offsets: None,
        shared_arena: None,
        arena_page_index: &arena_page_index,
        sdt_alloc_metas: &sdt_alloc_metas,
        cast_map: None,
        arena_slot_index: None,
        cross_btf_fwd_index: None,
        scx_static_index: None,
        alloc_size_types: &[],
        rendered_slot_addrs: None,
    };
    let rendered = super::render_map::render_map(&ctx, &info);
    // Only the first MAX_ARRAY_KEYS entries are walked.
    assert_eq!(
        rendered.array_entries.len(),
        MAX_ARRAY_KEYS as usize,
        "render must cap the walk at MAX_ARRAY_KEYS"
    );
    let err = rendered
        .error
        .expect("over-cap ARRAY must surface an error");
    assert!(
        err.contains(&format!(
            "ARRAY truncated at {MAX_ARRAY_KEYS} keys (max_entries={max_entries})"
        )),
        "error must carry the truncation note; got: {err}"
    );
    // The truncation note and the unreadable-count note join with
    // "; " — pin the separator so a future refactor that changes the
    // join (newline, or dropping one note when both fire) trips here.
    assert!(
        err.contains("; "),
        "joined-error format (truncation; unreadable count) drifted: {err}"
    );
}

/// A PERCPU_ARRAY declaring more keys than `MAX_PERCPU_KEYS` renders
/// only the first `MAX_PERCPU_KEYS` per-CPU entries and records the cap
/// in `error`. The PERCPU_ARRAY arm's truncation is index-driven
/// (`for key in 0..info.max_entries.min(MAX_PERCPU_KEYS)`), so it fires
/// regardless of whether per-CPU bytes are readable. This synthetic
/// guest has no `__per_cpu_offset` symbol, so every per-CPU value is
/// empty (`resolve_per_cpu_offsets` returns None and `read_percpu_array`
/// hands back an empty vec) — but a `FailureDumpPercpuEntry` slot is
/// still pushed per key, so the cap clamp and error string are the
/// behavior under test. Mirrors `render_map_multi_entry_array_truncates_at_cap`
/// for the PERCPU_ARRAY arm; the format-string-only
/// `pinned_error_percpu_array_truncation` stays as the cheap secondary
/// guard.
#[test]
fn render_map_percpu_array_truncates_at_cap() {
    let page_offset = crate::monitor::symbols::DEFAULT_PAGE_OFFSET;
    let buf = vec![0u8; 0x4000];
    // SAFETY: buf is a live local Vec<u8> outliving the GuestMem use.
    let mem =
        unsafe { super::super::reader::GuestMem::new(buf.as_ptr() as *mut u8, buf.len() as u64) };
    // Empty symbol map → no `__per_cpu_offset`, so the per-CPU deref
    // path yields empty values; the truncation flag is independent.
    let kernel = super::super::guest::GuestKernel::new_for_test(
        std::sync::Arc::new(mem),
        std::collections::HashMap::new(),
        page_offset,
        0,
        false,
    );
    let kernel_ref = unsafe { &*(&kernel as *const _) };
    let offsets = crate::monitor::btf_offsets::BpfMapOffsets::EMPTY;
    let accessor =
        super::super::bpf_map::GuestMemMapAccessor::new_for_test(kernel_ref, &offsets, 0);
    let (name_bytes, name_len) = name_from_str("test_percpu");
    // Declare more keys than the cap so the render arm truncates.
    let max_entries: u32 = MAX_PERCPU_KEYS + 71;
    let info = super::super::bpf_map::BpfMapInfo {
        map_pa: 0,
        map_kva: pa_to_kva(0x1000, page_offset),
        name_bytes,
        name_len,
        map_type: super::super::bpf_map::BPF_MAP_TYPE_PERCPU_ARRAY,
        map_flags: 0,
        key_size: 4,
        value_size: 4,
        max_entries,
        value_kva: Some(pa_to_kva(0x2000, page_offset)),
        btf_kva: 0,
        btf_value_type_id: 0,
        btf_vmlinux_value_type_id: 0,
        btf_key_type_id: 0,
    };
    let arena_page_index = super::render_map::ArenaPageIndex::new();
    let sdt_alloc_metas: Vec<super::render_map::SdtAllocMeta> = Vec::new();
    let ctx = super::render_map::RenderMapCtx {
        accessor: &accessor,
        btf: None,
        num_cpus: 1,
        arena_offsets: None,
        shared_arena: None,
        arena_page_index: &arena_page_index,
        sdt_alloc_metas: &sdt_alloc_metas,
        cast_map: None,
        arena_slot_index: None,
        cross_btf_fwd_index: None,
        scx_static_index: None,
        alloc_size_types: &[],
        rendered_slot_addrs: None,
    };
    let rendered = super::render_map::render_map(&ctx, &info);
    assert_eq!(
        rendered.percpu_entries.len(),
        MAX_PERCPU_KEYS as usize,
        "render must cap the PERCPU_ARRAY walk at MAX_PERCPU_KEYS"
    );
    // First/last walked keys are the kernel indices 0..MAX_PERCPU_KEYS.
    assert_eq!(rendered.percpu_entries[0].key, 0);
    assert_eq!(
        rendered.percpu_entries[MAX_PERCPU_KEYS as usize - 1].key,
        MAX_PERCPU_KEYS - 1
    );
    let err = rendered
        .error
        .expect("over-cap PERCPU_ARRAY must surface an error");
    assert_eq!(
        err,
        format!("PERCPU_ARRAY truncated at {MAX_PERCPU_KEYS} keys (max_entries={max_entries})"),
        "PERCPU_ARRAY truncation error must match the production format exactly; got: {err}"
    );
}

/// A HASH map holding more than `MAX_HASH_ENTRIES` (4096) live elements
/// renders only the first `MAX_HASH_ENTRIES` entries and records the cap
/// in `error`. Unlike the index-driven ARRAY/PERCPU_ARRAY arms, the HASH
/// arm's entry count comes from `iter_hash_map` walking real htab
/// buckets (the HASH render arm's `raw_entries.len() > MAX_HASH_ENTRIES`
/// then `.take(MAX_HASH_ENTRIES)` cap), so this builds a 4097-element
/// `hlist_nulls` chain in bucket 0 of a synthetic `bpf_htab` and runs the
/// real walker. The synthetic htab offsets reuse the exact field values
/// from the htab walker unit tests (`bpf_map::tests::htab_tests`) —
/// arbitrary but consistent with the walker's reads. The
/// format-string-only `pinned_error_hash_map_truncation` stays as the
/// cheap secondary guard.
#[test]
fn render_map_hash_truncates_at_cap() {
    use crate::monitor::btf_offsets::HtabOffsets;
    let page_offset = crate::monitor::symbols::DEFAULT_PAGE_OFFSET;
    // Synthetic htab field offsets (mirror of htab_tests::test_htab_offsets).
    let htab = HtabOffsets {
        htab_buckets: 200,
        htab_n_buckets: 208,
        bucket_size: 16,
        bucket_head: 0,
        hlist_nulls_head_first: 0,
        hlist_nulls_node_next: 0,
        htab_elem_size_base: 32,
    };
    // One bucket; every elem chains into bucket 0.
    let n_buckets: u32 = 1;
    let key_size: u32 = 4;
    let value_size: u32 = 4;
    // Build 4097 elements so the walk exceeds MAX_HASH_ENTRIES by one.
    let n_elems: usize = MAX_HASH_ENTRIES + 1;
    let htab_pa: u64 = 0x0000;
    let buckets_pa: u64 = 0x1000;
    let elems_start: u64 = 0x2000;
    let elem_stride: u64 = 64; // > htab_elem_size_base + key + value (32+8+4)
    let buf_size = elems_start as usize + n_elems * elem_stride as usize + 0x1000;
    let mut buf = vec![0u8; buf_size];
    let write_u32 = |buf: &mut Vec<u8>, pa: u64, val: u32| {
        let off = pa as usize;
        buf[off..off + 4].copy_from_slice(&val.to_ne_bytes());
    };
    let write_u64 = |buf: &mut Vec<u8>, pa: u64, val: u64| {
        let off = pa as usize;
        buf[off..off + 8].copy_from_slice(&val.to_ne_bytes());
    };
    // bpf_htab: buckets pointer + n_buckets.
    write_u64(
        &mut buf,
        htab_pa + htab.htab_buckets as u64,
        pa_to_kva(buckets_pa, page_offset),
    );
    write_u32(&mut buf, htab_pa + htab.htab_n_buckets as u64, n_buckets);
    // Bucket 0 head -> first elem KVA.
    let first_elem_pa = elems_start;
    write_u64(
        &mut buf,
        buckets_pa + htab.bucket_head as u64 + htab.hlist_nulls_head_first as u64,
        pa_to_kva(first_elem_pa, page_offset),
    );
    // Chain every elem; last elem's next is the nulls end marker (odd).
    for idx in 0..n_elems {
        let elem_pa = elems_start + (idx as u64) * elem_stride;
        let next = if idx + 1 < n_elems {
            pa_to_kva(elems_start + ((idx + 1) as u64) * elem_stride, page_offset)
        } else {
            1u64 // hlist_nulls end marker (bit 0 set)
        };
        write_u64(&mut buf, elem_pa + htab.hlist_nulls_node_next as u64, next);
        // Distinct key bytes per elem so torn/aliased reads would show.
        write_u32(
            &mut buf,
            elem_pa + htab.htab_elem_size_base as u64,
            idx as u32,
        );
        // Value at htab_elem_size_base + round_up(key_size, 8).
        let val_off = elem_pa + htab.htab_elem_size_base as u64 + ((key_size as u64 + 7) & !7);
        write_u32(&mut buf, val_off, (idx as u32).wrapping_mul(7));
    }
    // SAFETY: buf is a live local Vec<u8> outliving the GuestMem use.
    let mem =
        unsafe { super::super::reader::GuestMem::new(buf.as_ptr() as *mut u8, buf.len() as u64) };
    // Direct-mapping translation (kva = pa + page_offset); cr3 = 0 is
    // unused on the direct-map path the walker takes.
    let kernel = super::super::guest::GuestKernel::new_for_test(
        std::sync::Arc::new(mem),
        std::collections::HashMap::new(),
        page_offset,
        0,
        false,
    );
    let kernel_ref = unsafe { &*(&kernel as *const _) };
    let mut offsets = crate::monitor::btf_offsets::BpfMapOffsets::EMPTY;
    offsets.htab_offsets = Some(htab);
    let accessor =
        super::super::bpf_map::GuestMemMapAccessor::new_for_test(kernel_ref, &offsets, 0);
    let (name_bytes, name_len) = name_from_str("test_hash");
    let info = super::super::bpf_map::BpfMapInfo {
        map_pa: 0,
        map_kva: pa_to_kva(htab_pa, page_offset),
        name_bytes,
        name_len,
        map_type: super::super::bpf_map::BPF_MAP_TYPE_HASH,
        map_flags: 0,
        key_size,
        value_size,
        max_entries: n_elems as u32,
        value_kva: None,
        btf_kva: 0,
        btf_value_type_id: 0,
        btf_vmlinux_value_type_id: 0,
        btf_key_type_id: 0,
    };
    let arena_page_index = super::render_map::ArenaPageIndex::new();
    let sdt_alloc_metas: Vec<super::render_map::SdtAllocMeta> = Vec::new();
    let ctx = super::render_map::RenderMapCtx {
        accessor: &accessor,
        btf: None,
        num_cpus: 1,
        arena_offsets: None,
        shared_arena: None,
        arena_page_index: &arena_page_index,
        sdt_alloc_metas: &sdt_alloc_metas,
        cast_map: None,
        arena_slot_index: None,
        cross_btf_fwd_index: None,
        scx_static_index: None,
        alloc_size_types: &[],
        rendered_slot_addrs: None,
    };
    let rendered = super::render_map::render_map(&ctx, &info);
    assert_eq!(
        rendered.entries.len(),
        MAX_HASH_ENTRIES,
        "render must clamp the HASH entry vec to MAX_HASH_ENTRIES"
    );
    let err = rendered
        .error
        .expect("over-cap HASH must surface a truncation error");
    assert_eq!(
        err,
        format!("hash map truncated at {MAX_HASH_ENTRIES} entries"),
        "HASH truncation error must match the production format exactly; got: {err}"
    );
}

/// PERCPU_HASH render arm (its `BPF_MAP_TYPE_PERCPU_HASH | BPF_MAP_TYPE_LRU_PERCPU_HASH` match) — the worst
/// materialization case (per-CPU Vecs ×num_cpus). r23 bounds the walker
/// at MAP_MATERIALIZE_MAX+1 (via the shared `walk_htab`) so render
/// truncates to MAX_HASH_ENTRIES and reports it. Mirror of
/// `render_map_hash_truncates_at_cap` with map_type PERCPU_HASH; a
/// minimal `__per_cpu_offset` symbol (one zero offset) lets the
/// accessor resolve per-CPU offsets so the walk runs. Each elem's value
/// slot (the percpu pointer) is left 0, so the per-CPU deref
/// short-circuits to an empty per_cpu — the entry COUNT and truncation
/// (what r23 bounds) are independent of the per-CPU values, matching
/// `render_map_percpu_array_truncates_at_cap`'s approach.
#[test]
fn render_map_percpu_hash_truncates_at_cap() {
    use crate::monitor::btf_offsets::HtabOffsets;
    let page_offset = crate::monitor::symbols::DEFAULT_PAGE_OFFSET;
    let htab = HtabOffsets {
        htab_buckets: 200,
        htab_n_buckets: 208,
        bucket_size: 16,
        bucket_head: 0,
        hlist_nulls_head_first: 0,
        hlist_nulls_node_next: 0,
        htab_elem_size_base: 32,
    };
    let n_buckets: u32 = 1;
    let key_size: u32 = 4;
    let value_size: u32 = 4;
    let n_elems: usize = MAX_HASH_ENTRIES + 1;
    let htab_pa: u64 = 0x0000;
    let buckets_pa: u64 = 0x1000;
    let elems_start: u64 = 0x2000;
    let elem_stride: u64 = 64;
    // `__per_cpu_offset[]` storage sits in the padding past the elems.
    // `new_for_test` uses phys_base=0, so text_kva_to_pa(kva) = kva -
    // START_KERNEL_MAP; place the symbol so it resolves to pco_pa.
    let pco_pa: u64 = elems_start + n_elems as u64 * elem_stride + 0x100;
    let buf_size = elems_start as usize + n_elems * elem_stride as usize + 0x1000;
    let mut buf = vec![0u8; buf_size];
    let write_u32 = |buf: &mut Vec<u8>, pa: u64, val: u32| {
        let off = pa as usize;
        buf[off..off + 4].copy_from_slice(&val.to_ne_bytes());
    };
    let write_u64 = |buf: &mut Vec<u8>, pa: u64, val: u64| {
        let off = pa as usize;
        buf[off..off + 8].copy_from_slice(&val.to_ne_bytes());
    };
    write_u64(
        &mut buf,
        htab_pa + htab.htab_buckets as u64,
        pa_to_kva(buckets_pa, page_offset),
    );
    write_u32(&mut buf, htab_pa + htab.htab_n_buckets as u64, n_buckets);
    let first_elem_pa = elems_start;
    write_u64(
        &mut buf,
        buckets_pa + htab.bucket_head as u64 + htab.hlist_nulls_head_first as u64,
        pa_to_kva(first_elem_pa, page_offset),
    );
    for idx in 0..n_elems {
        let elem_pa = elems_start + (idx as u64) * elem_stride;
        let next = if idx + 1 < n_elems {
            pa_to_kva(elems_start + ((idx + 1) as u64) * elem_stride, page_offset)
        } else {
            1u64
        };
        write_u64(&mut buf, elem_pa + htab.hlist_nulls_node_next as u64, next);
        write_u32(
            &mut buf,
            elem_pa + htab.htab_elem_size_base as u64,
            idx as u32,
        );
        // Value slot (percpu pointer) left 0 → per-CPU deref short-circuits.
    }
    // SAFETY: buf is a live local Vec<u8> outliving the GuestMem use.
    let mem =
        unsafe { super::super::reader::GuestMem::new(buf.as_ptr() as *mut u8, buf.len() as u64) };
    let mut symbols = std::collections::HashMap::new();
    symbols.insert(
        "__per_cpu_offset".to_string(),
        crate::monitor::symbols::START_KERNEL_MAP + pco_pa,
    );
    let kernel = super::super::guest::GuestKernel::new_for_test(
        std::sync::Arc::new(mem),
        symbols,
        page_offset,
        0,
        false,
    );
    let kernel_ref = unsafe { &*(&kernel as *const _) };
    let mut offsets = crate::monitor::btf_offsets::BpfMapOffsets::EMPTY;
    offsets.htab_offsets = Some(htab);
    let accessor =
        super::super::bpf_map::GuestMemMapAccessor::new_for_test(kernel_ref, &offsets, 0);
    let (name_bytes, name_len) = name_from_str("test_percpu_hash");
    let info = super::super::bpf_map::BpfMapInfo {
        map_pa: 0,
        map_kva: pa_to_kva(htab_pa, page_offset),
        name_bytes,
        name_len,
        map_type: super::super::bpf_map::BPF_MAP_TYPE_PERCPU_HASH,
        map_flags: 0,
        key_size,
        value_size,
        max_entries: n_elems as u32,
        value_kva: None,
        btf_kva: 0,
        btf_value_type_id: 0,
        btf_vmlinux_value_type_id: 0,
        btf_key_type_id: 0,
    };
    let arena_page_index = super::render_map::ArenaPageIndex::new();
    let sdt_alloc_metas: Vec<super::render_map::SdtAllocMeta> = Vec::new();
    let ctx = super::render_map::RenderMapCtx {
        accessor: &accessor,
        btf: None,
        num_cpus: 1,
        arena_offsets: None,
        shared_arena: None,
        arena_page_index: &arena_page_index,
        sdt_alloc_metas: &sdt_alloc_metas,
        cast_map: None,
        arena_slot_index: None,
        cross_btf_fwd_index: None,
        scx_static_index: None,
        alloc_size_types: &[],
        rendered_slot_addrs: None,
    };
    let rendered = super::render_map::render_map(&ctx, &info);
    assert_eq!(
        rendered.percpu_hash_entries.len(),
        MAX_HASH_ENTRIES,
        "render must clamp the PERCPU_HASH entry vec to MAX_HASH_ENTRIES"
    );
    let err = rendered
        .error
        .expect("over-cap PERCPU_HASH must surface a truncation error");
    assert_eq!(
        err,
        format!("percpu hash map truncated at {MAX_HASH_ENTRIES} entries"),
        "PERCPU_HASH truncation error must match the production format exactly; got: {err}"
    );
}
/// A TASK_STORAGE map holding more than `MAX_HASH_ENTRIES` (4096) selems
/// renders only the first `MAX_HASH_ENTRIES` entries and records the cap
/// in `error`. The local-storage arm shares the HASH cap policy
/// (the local-storage render arm's `raw_entries.len() > MAX_HASH_ENTRIES` then
/// `.take(MAX_HASH_ENTRIES)` cap), and the entry count comes from
/// `iter_task_storage` walking a real `bpf_local_storage_map` hlist, so
/// this chains 4097 `bpf_local_storage_elem`s in bucket 0 and runs the
/// real walker. Synthetic offsets reuse the exact field values from the
/// local-storage walker unit tests
/// (`bpf_map::tests::local_storage_tests`). The format-string-only
/// `pinned_error_local_storage_truncation` stays as the cheap secondary
/// guard.
#[test]
fn render_map_local_storage_truncates_at_cap() {
    use crate::monitor::btf_offsets::TaskStorageOffsets;
    let page_offset = crate::monitor::symbols::DEFAULT_PAGE_OFFSET;
    // Synthetic local-storage offsets (mirror of
    // local_storage_tests::test_task_storage_offsets). bucket_log = 0
    // here, so n_buckets = 1 and all selems chain in bucket 0.
    let ts = TaskStorageOffsets {
        smap_buckets: 0,
        smap_bucket_log: 8,
        bucket_size: 16,
        bucket_list: 0,
        hlist_head_first: 0,
        hlist_node_next: 0,
        elem_local_storage: 16,
        elem_sdata: 24,
        sdata_data: 0,
        ls_owner: 0,
    };
    let value_size: u32 = 4;
    let n_elems: usize = MAX_HASH_ENTRIES + 1;
    let smap_pa: u64 = 0x0000;
    let buckets_pa: u64 = 0x1000;
    let elems_start: u64 = 0x2000;
    let elem_stride: u64 = 64; // > elem_sdata + sdata_data + value_size (24+0+4)
    let ls_start: u64 = 0x40_0000;
    let ls_stride: u64 = 64;
    let buf_size = ls_start as usize + n_elems * ls_stride as usize + 0x1000;
    let mut buf = vec![0u8; buf_size];
    let write_u32 = |buf: &mut Vec<u8>, pa: u64, val: u32| {
        let off = pa as usize;
        buf[off..off + 4].copy_from_slice(&val.to_ne_bytes());
    };
    let write_u64 = |buf: &mut Vec<u8>, pa: u64, val: u64| {
        let off = pa as usize;
        buf[off..off + 8].copy_from_slice(&val.to_ne_bytes());
    };
    // smap: bucket_log = 0 (one bucket) and buckets pointer.
    write_u32(&mut buf, smap_pa + ts.smap_bucket_log as u64, 0);
    write_u64(
        &mut buf,
        smap_pa + ts.smap_buckets as u64,
        pa_to_kva(buckets_pa, page_offset),
    );
    // Bucket 0 head -> first elem KVA.
    write_u64(
        &mut buf,
        buckets_pa + ts.bucket_list as u64 + ts.hlist_head_first as u64,
        pa_to_kva(elems_start, page_offset),
    );
    for idx in 0..n_elems {
        let elem_pa = elems_start + (idx as u64) * elem_stride;
        // Chain link at offset 0; NULL terminates the regular hlist.
        let next = if idx + 1 < n_elems {
            pa_to_kva(elems_start + ((idx + 1) as u64) * elem_stride, page_offset)
        } else {
            0u64 // regular hlist NULL termination (NOT hlist_nulls)
        };
        write_u64(&mut buf, elem_pa + ts.hlist_node_next as u64, next);
        // Value bytes at elem + elem_sdata + sdata_data.
        let value_off = elem_pa + ts.elem_sdata as u64 + ts.sdata_data as u64;
        write_u32(&mut buf, value_off, idx as u32);
        // bpf_local_storage container with a distinct owner KVA.
        let ls_pa = ls_start + (idx as u64) * ls_stride;
        write_u64(
            &mut buf,
            ls_pa + ts.ls_owner as u64,
            0xFFFF_8000_0000_0000 + idx as u64,
        );
        write_u64(
            &mut buf,
            elem_pa + ts.elem_local_storage as u64,
            pa_to_kva(ls_pa, page_offset),
        );
    }
    // SAFETY: buf is a live local Vec<u8> outliving the GuestMem use.
    let mem =
        unsafe { super::super::reader::GuestMem::new(buf.as_ptr() as *mut u8, buf.len() as u64) };
    let kernel = super::super::guest::GuestKernel::new_for_test(
        std::sync::Arc::new(mem),
        std::collections::HashMap::new(),
        page_offset,
        0,
        false,
    );
    let kernel_ref = unsafe { &*(&kernel as *const _) };
    let mut offsets = crate::monitor::btf_offsets::BpfMapOffsets::EMPTY;
    offsets.task_storage_offsets = Some(ts);
    let accessor =
        super::super::bpf_map::GuestMemMapAccessor::new_for_test(kernel_ref, &offsets, 0);
    let (name_bytes, name_len) = name_from_str("test_task_storage");
    let info = super::super::bpf_map::BpfMapInfo {
        map_pa: 0,
        map_kva: pa_to_kva(smap_pa, page_offset),
        name_bytes,
        name_len,
        map_type: super::super::bpf_map::BPF_MAP_TYPE_TASK_STORAGE,
        map_flags: 0,
        key_size: 0,
        value_size,
        max_entries: 0,
        value_kva: None,
        btf_kva: 0,
        btf_value_type_id: 0,
        btf_vmlinux_value_type_id: 0,
        btf_key_type_id: 0,
    };
    let arena_page_index = super::render_map::ArenaPageIndex::new();
    let sdt_alloc_metas: Vec<super::render_map::SdtAllocMeta> = Vec::new();
    let ctx = super::render_map::RenderMapCtx {
        accessor: &accessor,
        btf: None,
        num_cpus: 1,
        arena_offsets: None,
        shared_arena: None,
        arena_page_index: &arena_page_index,
        sdt_alloc_metas: &sdt_alloc_metas,
        cast_map: None,
        arena_slot_index: None,
        cross_btf_fwd_index: None,
        scx_static_index: None,
        alloc_size_types: &[],
        rendered_slot_addrs: None,
    };
    let rendered = super::render_map::render_map(&ctx, &info);
    assert_eq!(
        rendered.entries.len(),
        MAX_HASH_ENTRIES,
        "render must clamp the local_storage entry vec to MAX_HASH_ENTRIES"
    );
    let err = rendered
        .error
        .expect("over-cap local_storage must surface a truncation error");
    assert_eq!(
        err,
        format!("local_storage map truncated at {MAX_HASH_ENTRIES} entries"),
        "local_storage truncation error must match the production format exactly; got: {err}"
    );
}

/// `find_all_bpf_maps` populates `value_kva = kvalue + data_off` for
/// STRUCT_OPS maps when `struct_ops_offsets` is resolved. This test
/// covers the static math without the page-walk to confirm the
/// kvalue + data_off chain matches the spec from
/// `bpf_struct_ops_map_alloc`.
#[test]
fn struct_ops_value_kva_math_kvalue_plus_data() {
    let so = super::super::btf_offsets::StructOpsOffsets {
        kvalue: 0x40,
        value_data: 0x10,
    };
    let map_kva = 0xFFFF_8888_0000_0000u64;
    // Production calculation: map_kva + kvalue + value_data.
    let value_kva = map_kva
        .wrapping_add(so.kvalue as u64)
        .wrapping_add(so.value_data as u64);
    assert_eq!(value_kva, 0xFFFF_8888_0000_0050);
}

/// `populated > MAX_FD_ARRAY_INDICES`: indices vector caps at the
/// limit; populated continues to count beyond. The renderer flags
/// the divergence on `indices_truncated` so a downstream consumer
/// can see at a glance that the indices list is partial.
#[test]
fn render_fd_array_indices_capped_at_max_indices() {
    let cap = super::render_map::MAX_FD_ARRAY_INDICES as u32;
    // Populate cap + 5 slots; expect populated=cap+5, indices=cap.
    let pop: Vec<u32> = (0..cap + 5).collect();
    let (scene, info) = build_fd_array_scene(
        super::super::bpf_map::BPF_MAP_TYPE_PROG_ARRAY,
        cap + 5,
        &pop,
    );
    let mem = unsafe {
        super::super::reader::GuestMem::new(scene.buf.as_ptr() as *mut u8, scene.buf.len() as u64)
    };
    let kernel = super::super::guest::GuestKernel::new_for_test(
        std::sync::Arc::new(mem),
        std::collections::HashMap::new(),
        scene.page_offset,
        0,
        false,
    );
    let kernel_ref = unsafe { &*(&kernel as *const _) };
    let accessor =
        super::super::bpf_map::GuestMemMapAccessor::new_for_test(kernel_ref, &scene.offsets, 0);
    let fa = render_fd_array_slots(&accessor, &info);
    assert_eq!(
        fa.populated,
        cap + 5,
        "populated counts every non-zero slot"
    );
    assert_eq!(
        fa.indices.len() as u32,
        cap,
        "indices vector caps at MAX_FD_ARRAY_INDICES",
    );
    assert!(
        fa.indices_truncated,
        "populated > indices.len() must set indices_truncated",
    );
}

/// `dump_truncated_at_us` defaults to None and roundtrips through
/// serde with `skip_serializing_if`. The field is absent on the
/// wire when None (every healthy dump) and surfaces a u64 us-offset
/// when the soft deadline fires.
#[test]
fn report_dump_truncated_at_us_serde() {
    // None: field absent in JSON.
    let r = FailureDumpReport::default();
    let json = serde_json::to_string(&r).unwrap();
    assert!(
        !json.contains("dump_truncated_at_us"),
        "None must skip-serialize: {json}"
    );
    let parsed: FailureDumpReport = serde_json::from_str(&json).unwrap();
    assert_eq!(parsed.dump_truncated_at_us, None);

    // Some(us): field present and roundtrips.
    let r = FailureDumpReport {
        dump_truncated_at_us: Some(15_000),
        ..FailureDumpReport::default()
    };
    let json = serde_json::to_string(&r).unwrap();
    assert!(
        json.contains("\"dump_truncated_at_us\":15000"),
        "Some must serialize: {json}"
    );
    let parsed: FailureDumpReport = serde_json::from_str(&json).unwrap();
    assert_eq!(parsed.dump_truncated_at_us, Some(15_000));
}

/// `MAX_ENRICHED_TASKS` constant is non-zero and large enough to
/// cover any healthy SCX runnable_list depth (bounded by the
/// kernel's stall watchdog, well under 4096 in practice).
#[test]
fn max_enriched_tasks_constant_is_reasonable() {
    // Pin the constant so a future tightening / loosening is a
    // deliberate test edit, not a silent drift.
    assert_eq!(super::MAX_ENRICHED_TASKS, 4096);
}

// ---- Inline small struct rendering for FailureDumpEntry --------
//
// When a key or value is a struct with ≤ 3 non-zero non-fmt-string
// inline-scalar fields, Display collapses it into the
// `{field: value, ...}` form. When BOTH sides qualify, the entry
// renders on a single line. When only one qualifies, that side
// inlines and the other keeps its block form. Payload (when
// present) always renders as a block below the entry.

fn make_small_struct(type_name: &str, fields: &[(&str, u64)]) -> RenderedValue {
    RenderedValue::Struct {
        type_name: Some(type_name.into()),
        members: fields
            .iter()
            .map(|(n, v)| super::super::btf_render::RenderedMember {
                name: (*n).into(),
                value: RenderedValue::Uint {
                    bits: 64,
                    value: *v,
                },
            })
            .collect(),
    }
}

#[test]
fn entry_display_renders_inline_struct_key_and_value() {
    // With the consolidated indent-based format, each entry renders
    // as:
    //   `entry: key=<key>\n  value: <value>`
    // Small structs collapse onto their own line via the btf_render
    // inline form `Type{f=v, f=v}`.
    let entry = FailureDumpEntry {
        key: Some(make_small_struct(
            "cgroup_llc_id",
            &[("cgrp_id", 1), ("llc_id", 5)],
        )),
        key_hex: "01 05".into(),
        value: Some(make_small_struct(
            "cbw_llc_entry",
            &[("llcx", 17_592_186_046_336)],
        )),
        value_hex: "00".into(),
        payload: None,
    };
    let out = format!("{entry}");
    // Header line: `entry: key=cgroup_llc_id{cgrp_id=1, llc_id=5}`
    assert!(
        out.starts_with("entry: key="),
        "missing entry header: {out}",
    );
    assert!(out.contains("cgroup_llc_id{"), "key inline form: {out}");
    assert!(out.contains("cgrp_id=1"), "key field cgrp_id: {out}");
    assert!(out.contains("llc_id=5"), "key field llc_id: {out}");
    // Value line: indented `  value: cbw_llc_entry{...}`.
    assert!(
        out.contains("\n  value: "),
        "missing indented value line: {out}",
    );
    assert!(out.contains("cbw_llc_entry{"), "value inline form: {out}");
    assert!(out.contains("llcx=17592186046336"), "value field: {out}");
    // No `struct` keyword in inline form (it's been dropped from
    // the renderer entirely).
    assert!(
        !out.contains("struct cgroup_llc_id"),
        "inline form must drop `struct` prefix: {out}",
    );
    assert!(
        !out.contains("struct cbw_llc_entry"),
        "inline form must drop `struct` prefix: {out}",
    );
}

#[test]
fn entry_display_inline_zero_fields_dropped_silently() {
    // Zero fields are suppressed silently — no `(N fields zero)`
    // summary appears in the inline braces or anywhere else. The
    // operator infers from the rendered fields that the rest are
    // zero.
    let entry = FailureDumpEntry {
        key: Some(make_small_struct(
            "k",
            &[("real", 7), ("zero1", 0), ("zero2", 0)],
        )),
        key_hex: "07".into(),
        value: Some(make_small_struct("v", &[("real", 3)])),
        value_hex: "03".into(),
        payload: None,
    };
    let out = format!("{entry}");
    assert!(out.contains("real=7"), "non-zero key field present: {out}");
    assert!(
        out.contains("real=3"),
        "non-zero value field present: {out}"
    );
    assert!(!out.contains("zero1"), "zero fields are suppressed: {out}",);
    assert!(
        !out.contains("fields zero"),
        "no zero-count summary anywhere: {out}",
    );
}

#[test]
fn entry_display_value_falls_to_multi_line_when_too_wide() {
    // Value is a struct that's small enough by field count (5) but
    // its rendered inline form may exceed the inline width budget
    // — actually 5 small u64 fields fit comfortably. The btf_render
    // inline path handles arbitrary field counts so long as the
    // rendered length fits 120 chars; this test pins a value that
    // intentionally exceeds the budget to exercise the multi-line
    // breadcrumb fallback.
    let big_value = RenderedValue::Struct {
        type_name: Some("v".into()),
        members: (0..15)
            .map(|i| super::super::btf_render::RenderedMember {
                name: format!("very_long_field_name_{i}"),
                value: RenderedValue::Uint {
                    bits: 64,
                    value: 0x1234_5678_9abc_def0u64.wrapping_add(i as u64),
                },
            })
            .collect(),
    };
    let entry = FailureDumpEntry {
        key: Some(make_small_struct("k", &[("only", 1)])),
        key_hex: "01".into(),
        value: Some(big_value),
        value_hex: "01".into(),
        payload: None,
    };
    let out = format!("{entry}");
    // Multi-line breadcrumb: `value: v:` then indented field rows.
    // Block form has at least one extra newline beyond the header
    // and value lines.
    assert!(
        out.contains("\n  value: v:"),
        "multi-line value uses breadcrumb form: {out}",
    );
    // Key still inlines.
    assert!(out.contains("k{only=1}"), "key inline form: {out}");
}

#[test]
fn entry_display_payload_renders_below_value() {
    // Payload value renders below the entry on its own indented
    // line. The Display impl uses `\n  .data <rendered>` (with
    // a space, no colon) so the value's own breadcrumb completes
    // the line as `.data TypeName:` for multi-line structs or
    // just `.data <scalar>` for scalars.
    let entry = FailureDumpEntry {
        key: Some(make_small_struct("k", &[("a", 1)])),
        key_hex: "01".into(),
        value: Some(make_small_struct("v", &[("b", 2)])),
        value_hex: "02".into(),
        payload: Some(RenderedValue::Uint {
            bits: 64,
            value: 0xDEAD_BEEF,
        }),
    };
    let out = format!("{entry}");
    assert!(out.starts_with("entry: key="), "entry header: {out}");
    assert!(out.contains("\n  value: v{b=2}"), "value inline: {out}");
    // Payload on a separate line. Scalars don't have a breadcrumb,
    // so the rendered form is just the decimal number.
    assert!(out.contains("\n  .data "), ".data label must appear: {out}",);
    assert!(
        out.contains("3735928559"), // 0xDEADBEEF in decimal
        "rendered payload value must appear: {out}",
    );
}

// ---- Table rendering for FailureDumpMap -------------------------
//
// Homogeneous entries (every entry has key+value as same-shape
// inline-scalar struct, no payload) render as a compact table.
// Heterogeneous or non-qualifying batches fall back to the
// per-entry block form.

#[test]
fn map_display_table_for_homogeneous_entries() {
    let m = FailureDumpMap {
        name: "cbw".into(),
        map_kva: 0,
        map_type: BPF_MAP_TYPE_HASH,
        value_size: 8,
        max_entries: 64,
        value: None,
        entries: vec![
            FailureDumpEntry {
                key: Some(make_small_struct(
                    "cgroup_llc_id",
                    &[("cgrp_id", 1), ("llc_id", 5)],
                )),
                key_hex: "01 05".into(),
                value: Some(make_small_struct(
                    "cbw_llc_entry",
                    &[("llcx", 17_592_186_046_336)],
                )),
                value_hex: "00".into(),
                payload: None,
            },
            FailureDumpEntry {
                key: Some(make_small_struct(
                    "cgroup_llc_id",
                    &[("cgrp_id", 61), ("llc_id", 3)],
                )),
                key_hex: "3d 03".into(),
                value: Some(make_small_struct(
                    "cbw_llc_entry",
                    &[("llcx", 17_592_186_047_616)],
                )),
                value_hex: "00".into(),
                payload: None,
            },
            FailureDumpEntry {
                key: Some(make_small_struct(
                    "cgroup_llc_id",
                    &[("cgrp_id", 41), ("llc_id", 1)],
                )),
                key_hex: "29 01".into(),
                value: Some(make_small_struct(
                    "cbw_llc_entry",
                    &[("llcx", 17_592_186_047_040)],
                )),
                value_hex: "00".into(),
                payload: None,
            },
        ],
        array_entries: Vec::new(),
        percpu_entries: Vec::new(),
        percpu_hash_entries: Vec::new(),
        arena: None,
        ringbuf: None,
        stack_trace: None,
        fd_array: None,
        error: None,
    };
    let out = format!("{m}");
    // Header row carries column names with `|` separating key
    // from value columns.
    assert!(out.contains("cgrp_id"), "key column header missing: {out}",);
    assert!(out.contains("llc_id"), "key column header missing: {out}");
    assert!(out.contains("llcx"), "value column header missing: {out}");
    assert!(out.contains(" | "), "key/value separator missing: {out}");
    // Data rows carry the per-entry values.
    assert!(out.contains("17592186046336"), "row 0 value: {out}");
    assert!(out.contains("17592186047616"), "row 1 value: {out}");
    assert!(out.contains("17592186047040"), "row 2 value: {out}");
    // Per-entry block form should NOT appear (table replaced it).
    assert!(
        !out.contains("entry {"),
        "table form must replace per-entry blocks: {out}",
    );
}

#[test]
fn map_display_skips_table_for_single_entry() {
    // Single-entry maps fall through to per-entry rendering — the
    // table header overhead exceeds the savings.
    let m = FailureDumpMap {
        name: "single".into(),
        map_kva: 0,
        map_type: BPF_MAP_TYPE_HASH,
        value_size: 8,
        max_entries: 64,
        value: None,
        entries: vec![FailureDumpEntry {
            key: Some(make_small_struct("k", &[("a", 1)])),
            key_hex: "01".into(),
            value: Some(make_small_struct("v", &[("b", 2)])),
            value_hex: "02".into(),
            payload: None,
        }],
        array_entries: Vec::new(),
        percpu_entries: Vec::new(),
        percpu_hash_entries: Vec::new(),
        arena: None,
        ringbuf: None,
        stack_trace: None,
        fd_array: None,
        error: None,
    };
    let out = format!("{m}");
    // Per-entry rendering uses the indent-based format, with each
    // entry starting on its own line as `entry: key=...`. The
    // table form would have started with column headers like
    // `cgrp_id  llc_id |` — verify the per-entry path took over.
    assert!(
        out.contains("entry: key="),
        "single entry must keep per-entry rendering: {out}",
    );
    assert!(
        !out.contains(" | "),
        "single entry must not use table form: {out}",
    );
}

#[test]
fn map_display_skips_table_when_payload_present() {
    // Any entry with a payload disqualifies the whole batch — the
    // table can't carry the per-entry typed payload below each row.
    let m = FailureDumpMap {
        name: "with_payload".into(),
        map_kva: 0,
        map_type: BPF_MAP_TYPE_HASH,
        value_size: 8,
        max_entries: 64,
        value: None,
        entries: vec![
            FailureDumpEntry {
                key: Some(make_small_struct("k", &[("a", 1)])),
                key_hex: "01".into(),
                value: Some(make_small_struct("v", &[("b", 2)])),
                value_hex: "02".into(),
                payload: Some(RenderedValue::Uint {
                    bits: 64,
                    value: 99,
                }),
            },
            FailureDumpEntry {
                key: Some(make_small_struct("k", &[("a", 3)])),
                key_hex: "03".into(),
                value: Some(make_small_struct("v", &[("b", 4)])),
                value_hex: "04".into(),
                payload: None,
            },
        ],
        array_entries: Vec::new(),
        percpu_entries: Vec::new(),
        percpu_hash_entries: Vec::new(),
        arena: None,
        ringbuf: None,
        stack_trace: None,
        fd_array: None,
        error: None,
    };
    let out = format!("{m}");
    // Per-entry rendering used (each entry has `entry {` opener).
    assert!(
        out.contains("entry: key="),
        "payload-bearing batch must use per-entry form: {out}",
    );
    assert!(out.contains("\n  .data "), ".data still surfaces: {out}",);
}

#[test]
fn map_display_skips_table_for_heterogeneous_types() {
    // Different key type names → not homogeneous → no table.
    let m = FailureDumpMap {
        name: "het".into(),
        map_kva: 0,
        map_type: BPF_MAP_TYPE_HASH,
        value_size: 8,
        max_entries: 64,
        value: None,
        entries: vec![
            FailureDumpEntry {
                key: Some(make_small_struct("k1", &[("a", 1)])),
                key_hex: "01".into(),
                value: Some(make_small_struct("v", &[("b", 2)])),
                value_hex: "02".into(),
                payload: None,
            },
            FailureDumpEntry {
                key: Some(make_small_struct("k2", &[("a", 3)])),
                key_hex: "03".into(),
                value: Some(make_small_struct("v", &[("b", 4)])),
                value_hex: "04".into(),
                payload: None,
            },
        ],
        array_entries: Vec::new(),
        percpu_entries: Vec::new(),
        percpu_hash_entries: Vec::new(),
        arena: None,
        ringbuf: None,
        stack_trace: None,
        fd_array: None,
        error: None,
    };
    let out = format!("{m}");
    // Per-entry rendering, NOT a table.
    assert!(
        out.contains("entry: key="),
        "heterogeneous types must use per-entry form: {out}",
    );
}

#[test]
fn map_display_skips_table_when_entry_has_no_btf_render() {
    // Any entry with a None key or value (hex-only fallback)
    // disqualifies the table.
    let m = FailureDumpMap {
        name: "no_btf".into(),
        map_kva: 0,
        map_type: BPF_MAP_TYPE_HASH,
        value_size: 8,
        max_entries: 64,
        value: None,
        entries: vec![
            FailureDumpEntry {
                key: None,
                key_hex: "ab".into(),
                value: None,
                value_hex: "cd".into(),
                payload: None,
            },
            FailureDumpEntry {
                key: Some(make_small_struct("k", &[("a", 1)])),
                key_hex: "01".into(),
                value: Some(make_small_struct("v", &[("b", 2)])),
                value_hex: "02".into(),
                payload: None,
            },
        ],
        array_entries: Vec::new(),
        percpu_entries: Vec::new(),
        percpu_hash_entries: Vec::new(),
        arena: None,
        ringbuf: None,
        stack_trace: None,
        fd_array: None,
        error: None,
    };
    let out = format!("{m}");
    assert!(
        out.contains("entry: key="),
        "missing BTF render disqualifies table: {out}",
    );
    assert!(
        out.contains("ab (raw)"),
        "hex fallback must still surface: {out}",
    );
}

// -- append_arena_slot_index_for_allocator -----------------------
//
// Coverage for the index-build helper that the dump pre-pass calls
// per allocator. The helper handles size-fits-u32 conversion, the
// dedup-on-duplicate-slot-start `tracing::debug!` path, and the
// "no payload type" short-circuit. Tests run against a synthesized
// `Vec<SdtAllocEntry>` rather than booting a VM — the helper is a
// pure function over its inputs.

/// Construct an [`SdtAllocEntry`] for the index-build tests.
/// `payload` is set to a placeholder `Bytes` value — the index
/// build path only reads `user_addr`, so the rest is filler.
fn mk_alloc_entry(idx: i32, genn: i32, user_addr: u64) -> super::super::sdt_alloc::SdtAllocEntry {
    super::super::sdt_alloc::SdtAllocEntry {
        idx,
        genn,
        user_addr,
        payload: super::super::btf_render::RenderedValue::Bytes { hex: String::new() },
    }
}

/// `target_type_id == 0` short-circuits — the helper does not
/// produce any index entries because the bridge gate would filter
/// them as "no payload type" anyway. Pinning the early bail keeps
/// callers from accidentally polluting the index with zero ids.
#[test]
fn append_arena_slot_index_for_allocator_zero_target_type_id_skips() {
    use super::render_map::{ArenaSlotIndex, append_arena_slot_index_for_allocator};
    let mut index = ArenaSlotIndex::new();
    let addrs: Vec<u64> = vec![0x0000_1000];
    append_arena_slot_index_for_allocator(
        &mut index,
        "test_allocator",
        0, // target_type_id == 0 ⇒ short-circuit
        8,
        16,
        &addrs,
        0,
    );
    assert!(
        index.is_empty(),
        "zero target_type_id must skip every entry; got {} index entries",
        index.len(),
    );
}

/// `header_size` or `elem_size` that would not fit `u32` (only
/// reachable from a corrupted snapshot — the kernel caps both well
/// below `u32::MAX`) skips silently. Pinning the no-panic behaviour
/// keeps a torn read from aborting the whole dump.
#[test]
fn append_arena_slot_index_for_allocator_oversized_skips() {
    use super::render_map::{ArenaSlotIndex, append_arena_slot_index_for_allocator};
    let mut index = ArenaSlotIndex::new();
    let _entries = [mk_alloc_entry(0, 0, 0x0000_1000)];
    // elem_size > u32::MAX ⇒ try_from fails, helper bails.
    append_arena_slot_index_for_allocator(
        &mut index,
        "test_allocator",
        7,
        8,
        u64::from(u32::MAX) + 1,
        &[0x1000u64],
        0,
    );
    assert!(
        index.is_empty(),
        "elem_size > u32::MAX must skip every entry; got {} entries",
        index.len(),
    );
}

/// Multi-entry insert: each `SdtAllocEntry` becomes one index entry
/// keyed by `user_addr as u32` with the shared `ArenaSlotInfo`.
/// Pinning the per-allocator append shape so a future inner-loop
/// rewrite can't silently drop entries.
#[test]
fn append_arena_slot_index_for_allocator_multi_entry_insert() {
    use super::render_map::{ArenaSlotIndex, ArenaSlotInfo, append_arena_slot_index_for_allocator};
    let mut index = ArenaSlotIndex::new();
    let _entries = [
        mk_alloc_entry(0, 0, 0x0000_1000),
        mk_alloc_entry(1, 0, 0x0000_2000),
        mk_alloc_entry(2, 0, 0x0000_3000),
    ];
    append_arena_slot_index_for_allocator(
        &mut index,
        "test_allocator",
        7,
        8,
        16,
        &[0x1000, 0x2000, 0x3000],
        0,
    );
    let expected_info = ArenaSlotInfo {
        elem_size: 16,
        header_size: 8,
        target_type_id: 7,
        source_btf_kva: 0,
    };
    assert_eq!(index.len(), 3);
    assert_eq!(index.get(&0x0000_1000), Some(&expected_info));
    assert_eq!(index.get(&0x0000_2000), Some(&expected_info));
    assert_eq!(index.get(&0x0000_3000), Some(&expected_info));
}

/// Duplicate `slot_start` keeps the FIRST entry. The
/// `tracing::debug!` line for the collision is not asserted — the
/// behaviour test is "vacant wins, occupied keeps prior value". Pin
/// the dedup policy against a future flip to last-wins (which would
/// silently overwrite a live slot's metadata with a stale one when
/// a freed allocation racing the freeze surfaces in two passes).
#[test]
fn append_arena_slot_index_for_allocator_duplicate_slot_keeps_first() {
    use super::render_map::{ArenaSlotIndex, ArenaSlotInfo, append_arena_slot_index_for_allocator};
    let mut index = ArenaSlotIndex::new();
    // First call seeds slot 0x1000 with payload type 7.
    let _entries_first = [mk_alloc_entry(0, 0, 0x0000_1000)];
    append_arena_slot_index_for_allocator(&mut index, "alloc_a", 7, 8, 16, &[0x1000u64], 0);
    // Second call tries to insert the same slot start with a
    // distinct payload type 11 (e.g. a stale snapshot after free
    // racing the freeze). Helper must keep the first entry.
    let _entries_second = [mk_alloc_entry(0, 0, 0x0000_1000)];
    append_arena_slot_index_for_allocator(&mut index, "alloc_b", 11, 8, 16, &[0x1000u64], 0);
    assert_eq!(index.len(), 1);
    assert_eq!(
        index.get(&0x0000_1000),
        Some(&ArenaSlotInfo {
            elem_size: 16,
            header_size: 8,
            target_type_id: 7,
            source_btf_kva: 0,
        }),
        "duplicate slot_start must keep first entry's payload type",
    );
}

/// Two distinct allocators contribute non-overlapping slot ranges
/// to one index. Pinning the multi-allocator merge against a
/// regression where one allocator's metadata might silently
/// overwrite another's because both used the same low-32 windowed
/// keys.
#[test]
fn append_arena_slot_index_for_allocator_multi_allocator_merge() {
    use super::render_map::{ArenaSlotIndex, ArenaSlotInfo, append_arena_slot_index_for_allocator};
    let mut index = ArenaSlotIndex::new();
    // Allocator A — payload type 7, two slots.
    let _entries_a = [
        mk_alloc_entry(0, 0, 0x0000_1000),
        mk_alloc_entry(1, 0, 0x0000_2000),
    ];
    append_arena_slot_index_for_allocator(
        &mut index,
        "alloc_a",
        7,
        8,
        16,
        &[0x1000u64, 0x2000u64],
        0,
    );
    // Allocator B — payload type 11, two distinct slots.
    let _entries_b = [
        mk_alloc_entry(0, 0, 0x0000_3000),
        mk_alloc_entry(1, 0, 0x0000_4000),
    ];
    append_arena_slot_index_for_allocator(
        &mut index,
        "alloc_b",
        11,
        8,
        16,
        &[0x3000u64, 0x4000u64],
        0,
    );
    let info_a = ArenaSlotInfo {
        elem_size: 16,
        header_size: 8,
        target_type_id: 7,
        source_btf_kva: 0,
    };
    let info_b = ArenaSlotInfo {
        elem_size: 16,
        header_size: 8,
        target_type_id: 11,
        source_btf_kva: 0,
    };
    assert_eq!(index.len(), 4);
    assert_eq!(index.get(&0x0000_1000), Some(&info_a));
    assert_eq!(index.get(&0x0000_2000), Some(&info_a));
    assert_eq!(index.get(&0x0000_3000), Some(&info_b));
    assert_eq!(index.get(&0x0000_4000), Some(&info_b));
}

// -- multi-allocator resolve through resolve_arena_type_in_index --
//
// Pins the end-to-end chain `append_arena_slot_index_for_allocator`
// (per allocator) → `resolve_arena_type_in_index` (per chase) when
// TWO distinct allocators contribute slots to the same per-pass
// `ArenaSlotIndex`. The bug surface is that per-cgroup
// arena pointers (`cgx_raw`, `llcx_raw`) chase through `resolve_arena_type`
// against an index that holds slots from BOTH the per-task allocator
// AND the per-cgroup allocator. If the merge drops the second
// allocator's payload type id (e.g. the dedup logic flattens to a
// global "first wins" across allocators rather than per-slot-start),
// `resolve_arena_type_in_index` returns the wrong type id at the
// per-cgroup slot start — silent miscoding rather than a clean None.
//
// The existing `multi_allocator_merge` test pins the index BUILD
// shape (each allocator's slots present); this test pins the
// LOOKUP shape (each allocator's slots resolve to the right
// payload).

/// Two allocators contribute non-overlapping slots to one index.
/// `resolve_arena_type` at each allocator's slot start returns
/// that allocator's payload type id with `header_skip = header_size`,
/// AND at each allocator's payload start returns the same id with
/// `header_skip = 0`. Pinning the per-allocator distinction across
/// the full index lookup, not just the index storage shape.
#[test]
fn resolve_arena_type_picks_correct_payload_when_multiple_allocators_in_index() {
    use super::super::arena::ArenaSnapshot;
    use super::super::btf_render::ArenaResolveHit;
    use super::render_map::{ArenaSlotIndex, append_arena_slot_index_for_allocator};

    // Per-task allocator: payload type 7 (e.g. `task_ctx`),
    // header_size=8, elem_size=24. Two slots at 0x1000 and 0x2000.
    // Per-cgroup allocator: payload type 11 (e.g. `scx_cgroup_ctx`),
    // header_size=8, elem_size=32. Two slots at 0x3000 and 0x4000.
    // Distinct elem_size pins that per-allocator metadata survives
    // the merge — a bug that flattened would surface as the wrong
    // elem_size in `ArenaSlotInfo`, which `resolve_arena_type_in_index`
    // uses to bound the slot's range.
    const TASK_TYPE_ID: u32 = 7;
    const CGRP_TYPE_ID: u32 = 11;
    const TASK_HEADER: usize = 8;
    const CGRP_HEADER: usize = 8;
    const TASK_ELEM: u64 = 24;
    const CGRP_ELEM: u64 = 32;

    let mut index = ArenaSlotIndex::new();
    append_arena_slot_index_for_allocator(
        &mut index,
        "scx_task_allocator",
        TASK_TYPE_ID,
        TASK_HEADER,
        TASK_ELEM,
        &[0x1000u64, 0x2000u64],
        0,
    );
    append_arena_slot_index_for_allocator(
        &mut index,
        "scx_cgroup_allocator",
        CGRP_TYPE_ID,
        CGRP_HEADER,
        CGRP_ELEM,
        &[0x3000u64, 0x4000u64],
        0,
    );
    assert_eq!(
        index.len(),
        4,
        "all four slots from two allocators must merge: {index:?}"
    );

    let snap = ArenaSnapshot {
        user_vm_start: 0x10_0000_0000,
        ..ArenaSnapshot::default()
    };
    let r = ResolveArenaTypeStub {
        arena_snapshot: Some(&snap),
        arena_slot_index: Some(&index),
    };

    // Task allocator slot 0x1000 — slot-start chase.
    assert_eq!(
        r.resolve_arena_type(0x10_0000_1000),
        Some(ArenaResolveHit {
            target_type_id: TASK_TYPE_ID,
            header_skip: TASK_HEADER,
        }),
        "slot-start chase at task allocator's 0x1000 must resolve to TASK_TYPE_ID",
    );
    // Task allocator slot 0x1000 — payload-start chase.
    assert_eq!(
        r.resolve_arena_type(0x10_0000_1008),
        Some(ArenaResolveHit {
            target_type_id: TASK_TYPE_ID,
            header_skip: 0,
        }),
        "payload-start chase at task allocator's 0x1008 must resolve to TASK_TYPE_ID",
    );
    // Cgroup allocator slot 0x3000 — slot-start chase. The bug
    // surface: if the merge collapses cgrp payload to task payload,
    // this returns TASK_TYPE_ID instead of CGRP_TYPE_ID.
    assert_eq!(
        r.resolve_arena_type(0x10_0000_3000),
        Some(ArenaResolveHit {
            target_type_id: CGRP_TYPE_ID,
            header_skip: CGRP_HEADER,
        }),
        "slot-start chase at cgroup allocator's 0x3000 must resolve to CGRP_TYPE_ID, \
         not TASK_TYPE_ID — pins per-allocator distinction across the full index",
    );
    // Cgroup allocator slot 0x3000 — payload-start chase.
    assert_eq!(
        r.resolve_arena_type(0x10_0000_3008),
        Some(ArenaResolveHit {
            target_type_id: CGRP_TYPE_ID,
            header_skip: 0,
        }),
        "payload-start chase at cgroup allocator's 0x3008 must resolve to CGRP_TYPE_ID",
    );
    // Cgroup allocator slot 0x4000 — slot-start chase.
    assert_eq!(
        r.resolve_arena_type(0x10_0000_4000),
        Some(ArenaResolveHit {
            target_type_id: CGRP_TYPE_ID,
            header_skip: CGRP_HEADER,
        }),
    );
    // Cgroup allocator slot 0x4000 — last byte INSIDE the slot range.
    // CGRP_ELEM=32 → range [0x4000, 0x4020). 0x401F is the last
    // byte; it's mid-payload (offset_in_slot=31, > header_size 8 and
    // != header_size) so the lookup falls to the mid-payload branch
    // and returns None. The critical property: the bound check uses
    // CGRP_ELEM (32), not TASK_ELEM (24) — a regression that
    // overwrote ArenaSlotInfo across allocators would use the wrong
    // elem_size and either accept this address (under TASK_ELEM=24,
    // 0x401F > 0x4000+24=0x4018, so out-of-range → None) or accept
    // earlier addresses spuriously.
    assert!(
        r.resolve_arena_type(0x10_0000_401F).is_none(),
        "last byte of cgroup slot must reach mid-payload (None), proving \
         the bound check used CGRP_ELEM=32 (not the task allocator's 24)",
    );
    // Address at 0x10_0000_4019 — past TASK_ELEM (24) but within
    // CGRP_ELEM (32). Confirms per-allocator elem_size honored:
    // under task elem_size this address would NOT belong to slot
    // 0x4000; under cgroup elem_size it does (mid-payload → None).
    // If the merge silently used the task elem_size for cgroup
    // slots, this address would NOT be in any range and we'd hit
    // the `key < slot_start` early reject. The current production
    // code returns None either way (mid-payload reject vs out-of-range
    // reject), so this assertion is just `is_none()`. The diagnostic
    // value is: when this test fails, the implementer knows the
    // merge dropped per-slot elem_size.
    assert!(
        r.resolve_arena_type(0x10_0000_4019).is_none(),
        "mid-payload address inside cgroup slot's elem_size range must \
         resolve via the cgroup slot's metadata (mid-payload branch returns None)",
    );
    // Out-of-window: same gates fire regardless of how many
    // allocators populated the index.
    assert!(
        r.resolve_arena_type(0x05_0000_3000).is_none(),
        "below-window cgroup-slot collision must be rejected by is_arena_addr",
    );
}

// -- resolve_cross_btf_fwd_in_index --------------------------------
//
// The free helper backs [`AccessorMemReader::cross_btf_resolve_fwd`].
// Tests below exercise gates that are difficult to reach through a
// full `GuestKernel` mock: aggregate-kind mismatch is the most
// important — the indexer keeps Struct/Union entries tagged in
// production, but the helper still validates the kind at lookup
// time against the caller's [`FwdKind`] argument because (a) the
// index format does not encode the kind and (b) a future indexer
// rewrite could let a same-name Union entry slip through that the
// caller specifically asked for as a Struct (or vice versa).

/// Build a minimal `.BTF` blob containing a single named
/// `BTF_KIND_STRUCT` so the helper's `resolve_type_by_id` succeeds
/// for the caller-supplied id. Returns `(blob, struct_foo_id)`.
/// Inlined here rather than reusing `cast_analysis_load::tests`
/// builders because the dump tests module is not in that file's
/// `cfg(test)` scope.
fn build_btf_with_named_struct(name: &str) -> (Vec<u8>, u32) {
    use std::io::Write;
    // String section: leading NUL + "u64\0" + "<name>\0" + "x\0".
    let mut strings: Vec<u8> = vec![0];
    let n_u64 = strings.len() as u32;
    strings.extend_from_slice(b"u64");
    strings.push(0);
    let n_struct = strings.len() as u32;
    strings.extend_from_slice(name.as_bytes());
    strings.push(0);
    let n_x = strings.len() as u32;
    strings.extend_from_slice(b"x");
    strings.push(0);

    // Type section: id 1 = BTF_KIND_INT u64, id 2 = BTF_KIND_STRUCT
    // <name> { u64 x @ 0 }.
    const BTF_KIND_INT: u32 = 1;
    const BTF_KIND_STRUCT: u32 = 4;
    let mut types: Vec<u8> = Vec::new();
    // id 1: Int u64.
    types.extend_from_slice(&n_u64.to_le_bytes());
    let int_info = (BTF_KIND_INT << 24) & 0x1f00_0000;
    types.extend_from_slice(&int_info.to_le_bytes());
    types.extend_from_slice(&8u32.to_le_bytes()); // size
    let int_data: u32 = 64;
    types.extend_from_slice(&int_data.to_le_bytes()); // encoding=0, offset=0, bits=64
    // id 2: Struct <name> { u64 x @ bit 0 }.
    types.extend_from_slice(&n_struct.to_le_bytes());
    let struct_info = ((BTF_KIND_STRUCT << 24) & 0x1f00_0000) | 1u32; // vlen=1
    types.extend_from_slice(&struct_info.to_le_bytes());
    types.extend_from_slice(&8u32.to_le_bytes()); // size
    types.extend_from_slice(&n_x.to_le_bytes()); // member name_off
    types.extend_from_slice(&1u32.to_le_bytes()); // member type id (u64)
    types.extend_from_slice(&0u32.to_le_bytes()); // bit_offset

    // Header (24 bytes) + type section + string section.
    let type_len = types.len() as u32;
    let str_len = strings.len() as u32;
    let mut blob: Vec<u8> = Vec::new();
    blob.write_all(&0xEB9F_u16.to_le_bytes()).unwrap(); // magic
    blob.push(1); // version
    blob.push(0); // flags
    blob.write_all(&24u32.to_le_bytes()).unwrap(); // hdr_len
    blob.write_all(&0u32.to_le_bytes()).unwrap(); // type_off
    blob.write_all(&type_len.to_le_bytes()).unwrap();
    blob.write_all(&type_len.to_le_bytes()).unwrap(); // str_off = type_len
    blob.write_all(&str_len.to_le_bytes()).unwrap();
    blob.extend_from_slice(&types);
    blob.extend_from_slice(&strings);
    (blob, 2)
}

/// Aggregate-kind gate fires: index has `("foo", FwdIndexEntry { 0,
/// struct_foo_id })` pointing at a `BTF_KIND_STRUCT`, but the
/// caller queries with [`FwdKind::Union`]. The helper's kind-match
/// arm in [`super::render_map::resolve_cross_btf_fwd_in_index`]
/// rejects with `None`, dropping the chase back to the historical
/// Fwd skip.
///
/// Without this gate, a same-name Union body in a sibling BTF
/// could surface for a caller that asked for a Struct (and
/// vice versa), corrupting the rendered subtree's layout
/// interpretation. Pin the rejection so a future indexer rewrite
/// that admits Union entries cannot silently bypass the kind
/// check.
#[test]
fn resolve_cross_btf_fwd_in_index_rejects_kind_mismatch() {
    use crate::monitor::btf_render::FwdKind;
    use crate::vmm::cast_analysis_load::FwdIndexEntry;
    use std::sync::Arc;

    // Build a sibling BTF whose `foo` is a Struct (id 2). The
    // index will key `foo -> (0, 2)`; the kind-match check in
    // the helper resolves type id 2, sees it's a Struct, and
    // when the caller asks for [`FwdKind::Union`] returns None.
    let (blob, struct_foo_id) = build_btf_with_named_struct("foo");
    let btf = Arc::new(btf_rs::Btf::from_bytes(&blob).expect("synthetic BTF parses"));
    let btfs = vec![btf];
    let mut fwd_index: std::collections::HashMap<String, FwdIndexEntry> =
        std::collections::HashMap::new();
    fwd_index.insert(
        "foo".to_string(),
        FwdIndexEntry {
            btfs_idx: 0,
            type_id: struct_foo_id,
        },
    );
    let cross = super::CrossBtfFwdIndex {
        btfs: &btfs,
        fwd_index: &fwd_index,
    };
    // Query with [`FwdKind::Union`] (caller is asking for a Union
    // body). The index entry is a Struct → kind mismatch → helper
    // returns None.
    let result =
        super::render_map::resolve_cross_btf_fwd_in_index(Some(&cross), "foo", FwdKind::Union);
    assert!(
        result.is_none(),
        "kind mismatch (Struct entry, FwdKind::Union) must reject; \
         got Some(...)",
    );

    // Sanity: same query with [`FwdKind::Struct`] succeeds —
    // proves the index lookup itself works and the kind gate is
    // the rejection cause, not an unrelated absence.
    let success =
        super::render_map::resolve_cross_btf_fwd_in_index(Some(&cross), "foo", FwdKind::Struct);
    assert!(
        success.is_some(),
        "matching kind (Struct entry, FwdKind::Struct) must succeed; \
         this confirms the rejection above is the kind gate firing",
    );
}

// -- collect_per_cpu_time KASLR coverage ------------------------------
//
// End-to-end coverage that `collect_per_cpu_time` threads
// [`CpuTimeCapture::kaslr_offset`] through the per-CPU KVA helper
// (see [`crate::monitor::symbols::per_cpu_kva`]) for cpustat,
// kstat, AND the optional tick_sched read. The helper has its own
// algebra tests at `monitor/symbols.rs::per_cpu_kva_*`; the tests
// below pin the wiring through `collect_per_cpu_time` end-to-end
// so a regression that drops the `kaslr_offset` arg from any of
// the three call sites in `collect_per_cpu_time` lands here as a
// "wrong per-CPU read" failure instead of silently picking the
// link-time slot when `CONFIG_RANDOMIZE_BASE=y`.

/// Backing fixture for the per_cpu_time tests. Owns the Vec<u8>
/// guest-memory buffer so the [`GuestMem`] view stays valid for
/// the test body's lifetime; the cpu-time template KVAs +
/// CpuTimeOffsets + per-CPU layout are picked to land each CPU's
/// reads at known PAs inside the buffer.
///
/// Memory layout (page_offset = 0 — PA = KVA):
///   - cpustat template KVA: 0x1000
///   - kstat   template KVA: 0x2000
///   - tick    template KVA: 0x3000
///   - per-CPU stride: 0x4000 (CPU 0 at +0x0, CPU 1 at +0x4000)
///
/// CpuTimeOffsets in-struct layout:
///   - kernel_cpustat.cpustat[]   at struct offset 0  (8 u64 = 64B)
///   - kernel_stat.irqs_sum       at struct offset 0  (1 u64 = 8B)
///   - kernel_stat.softirqs[]     at struct offset 8  (NR_SOFTIRQS u32)
///   - tick_sched.iowait_sleeptime at struct offset 0 (1 u64 = 8B)
struct PerCpuTimeScene {
    buf: Vec<u8>,
    cpustat_template_kva: u64,
    kstat_template_kva: u64,
    tick_template_kva: u64,
    per_cpu_offsets: Vec<u64>,
    offsets: super::super::btf_offsets::CpuTimeOffsets,
}

impl PerCpuTimeScene {
    fn build(num_cpus: usize, per_cpu_stride: u64) -> Self {
        // Templates must sit in the kernel-half (>= 1 << 48) so the
        // storage-form detection in `per_cpu_kva` classifies them as
        // post-v6.15 high-half percpu and applies the kaslr slide.
        // Pair each template with a matching `page_offset` so the
        // resulting PA = (template - page_offset) + kaslr + per_cpu_off
        // stays small and fits in the synthetic buffer.
        const HIGH_HALF_BASE: u64 = 0xffff_0000_0000_0000;
        let cpustat_template_kva = HIGH_HALF_BASE + 0x1000;
        let kstat_template_kva = HIGH_HALF_BASE + 0x2000;
        let tick_template_kva = HIGH_HALF_BASE + 0x3000;
        let per_cpu_offsets: Vec<u64> = (0..num_cpus as u64).map(|i| i * per_cpu_stride).collect();
        // Buffer must span every CPU's largest read offset.  The
        // max PA we touch is (tick_template - page_offset) +
        // max_per_cpu_off + sizeof(u64).  Round up to 0x1000
        // alignment + 0x1000 pad.
        let max_off = per_cpu_offsets.last().copied().unwrap_or(0);
        let span = (tick_template_kva - HIGH_HALF_BASE) + max_off + 0x1000;
        let buf = vec![0u8; span as usize];
        let offsets = super::super::btf_offsets::CpuTimeOffsets {
            kernel_cpustat_cpustat: 0,
            kstat_irqs_sum: 0,
            kstat_softirqs: 8,
            tick_sched_iowait_sleeptime: Some(0),
        };
        Self {
            buf,
            cpustat_template_kva,
            kstat_template_kva,
            tick_template_kva,
            per_cpu_offsets,
            offsets,
        }
    }

    /// Compute the PA that `collect_per_cpu_time` will read for
    /// `template_kva` on `cpu`, applying the per-cpu offset + the
    /// supplied KASLR offset. Mirrors the production formula
    /// `kva_to_pa(per_cpu_kva(template, kaslr, off), page_offset)`
    /// at [`crate::monitor::symbols::per_cpu_kva`] +
    /// [`crate::monitor::symbols::kva_to_pa`] explicitly so a
    /// future refactor that changes either helper's algebra
    /// surfaces here at the fixture rather than as opaque test
    /// breakage.
    fn pa_for(&self, template_kva: u64, cpu: usize, kaslr_offset: u64) -> u64 {
        super::super::symbols::kva_to_pa(
            super::super::symbols::per_cpu_kva(
                template_kva,
                kaslr_offset,
                self.per_cpu_offsets[cpu],
            ),
            Self::PAGE_OFFSET,
        )
    }

    /// Direct-map base used by the synthetic fixture.  Templates
    /// are linked at `PAGE_OFFSET + small_offset`, and the
    /// production capture under test is configured with the same
    /// `page_offset` so the resulting PA collapses to
    /// `small_offset + kaslr_offset + per_cpu_off` and fits in the
    /// buffer.
    const PAGE_OFFSET: u64 = 0xffff_0000_0000_0000;

    /// Write `value` as 8 LE bytes at `pa` in the backing buffer.
    fn write_u64_at(&mut self, pa: u64, value: u64) {
        let pa_usize = pa as usize;
        self.buf[pa_usize..pa_usize + 8].copy_from_slice(&value.to_le_bytes());
    }

    /// Write `value` as 4 LE bytes at `pa` in the backing buffer.
    fn write_u32_at(&mut self, pa: u64, value: u32) {
        let pa_usize = pa as usize;
        self.buf[pa_usize..pa_usize + 4].copy_from_slice(&value.to_le_bytes());
    }
}

/// Smoke baseline: `kaslr_offset = 0` reads each CPU's slot
/// straight from the per-cpu offset (no KASLR shift). Sanity
/// check that the test fixture is constructed correctly before
/// the non-zero-KASLR test exercises the bug-fix path.
#[test]
fn collect_per_cpu_time_zero_kaslr_reads_per_cpu_slot() {
    let mut scene = PerCpuTimeScene::build(2, 0x4000);
    let kaslr = 0u64;

    // CPU 0: write a recognizable cpustat[USER] value, a softirqs[3]
    // value, an irqs_sum, an iowait_sleeptime.
    let cpu0_cpustat_pa = scene.pa_for(scene.cpustat_template_kva, 0, kaslr);
    let cpu0_kstat_pa = scene.pa_for(scene.kstat_template_kva, 0, kaslr);
    let cpu0_tick_pa = scene.pa_for(scene.tick_template_kva, 0, kaslr);
    scene.write_u64_at(cpu0_cpustat_pa, 1_111u64); // CPUTIME_USER (slot 0)
    scene.write_u64_at(cpu0_cpustat_pa + 5 * 8, 5_555u64); // CPUTIME_IDLE (slot 5)
    scene.write_u64_at(cpu0_kstat_pa, 9_001u64); // irqs_sum at offset 0
    scene.write_u32_at(cpu0_kstat_pa + 8 + 3 * 4, 333u32); // softirqs[3] at +8 then *4
    scene.write_u64_at(cpu0_tick_pa, 42_000u64); // iowait_sleeptime at offset 0

    // CPU 1: distinct values so a regression that aliases CPUs
    // (e.g. drops per_cpu_off from the formula) trips here.
    let cpu1_cpustat_pa = scene.pa_for(scene.cpustat_template_kva, 1, kaslr);
    let cpu1_kstat_pa = scene.pa_for(scene.kstat_template_kva, 1, kaslr);
    let cpu1_tick_pa = scene.pa_for(scene.tick_template_kva, 1, kaslr);
    scene.write_u64_at(cpu1_cpustat_pa, 2_222u64);
    scene.write_u64_at(cpu1_cpustat_pa + 5 * 8, 6_666u64);
    scene.write_u64_at(cpu1_kstat_pa, 9_002u64);
    scene.write_u32_at(cpu1_kstat_pa + 8 + 3 * 4, 444u32);
    scene.write_u64_at(cpu1_tick_pa, 43_000u64);

    // SAFETY: scene.buf is a live local Vec<u8>; the GuestMem
    // view does not outlive this test body.
    let mem = unsafe {
        super::super::reader::GuestMem::new(scene.buf.as_mut_ptr(), scene.buf.len() as u64)
    };
    let capture = super::CpuTimeCapture {
        mem: &mem,
        offsets: &scene.offsets,
        kernel_cpustat_kva: scene.cpustat_template_kva,
        kstat_kva: scene.kstat_template_kva,
        tick_cpu_sched_kva: Some(scene.tick_template_kva),
        per_cpu_offsets: &scene.per_cpu_offsets,
        page_offset: PerCpuTimeScene::PAGE_OFFSET,
        kaslr_offset: kaslr,
    };
    let out = super::collect_per_cpu_time(&capture);
    assert_eq!(out.len(), 2);
    assert_eq!(out[0].cpu, 0);
    assert_eq!(out[0].cpustat_user_ns, 1_111);
    assert_eq!(out[0].cpustat_idle_ns, 5_555);
    assert_eq!(out[0].irqs_sum, 9_001);
    assert_eq!(out[0].softirqs[3], 333);
    assert_eq!(out[0].iowait_sleeptime_ns, Some(42_000));
    assert_eq!(out[1].cpu, 1);
    assert_eq!(out[1].cpustat_user_ns, 2_222);
    assert_eq!(out[1].cpustat_idle_ns, 6_666);
    assert_eq!(out[1].irqs_sum, 9_002);
    assert_eq!(out[1].softirqs[3], 444);
    assert_eq!(out[1].iowait_sleeptime_ns, Some(43_000));
}

/// `collect_per_cpu_time` adds `kaslr_offset` to every per-cpu
/// KVA — cpustat, kstat, and tick_cpu_sched. A regression that
/// drops the `kaslr_offset` arg from any of the three
/// `per_cpu_kva` call sites in `collect_per_cpu_time` reads from
/// the link-time slot instead of the runtime slot, silently
/// returning zeros (or stale neighbour bytes). Pins the wiring
/// end-to-end: write known values at the KASLR-shifted PAs,
/// verify each field reads the right value.
#[test]
fn collect_per_cpu_time_non_zero_kaslr_shifts_per_cpu_reads() {
    let mut scene = PerCpuTimeScene::build(2, 0x4000);
    // Non-zero, page-aligned kaslr slide. Small enough that
    // template + kaslr + per_cpu_off fits in the buffer span.
    let kaslr = 0x100u64;

    // CPU 0 at KASLR-shifted PAs (template + kaslr + 0):
    let cpu0_cpustat_pa = scene.pa_for(scene.cpustat_template_kva, 0, kaslr);
    let cpu0_kstat_pa = scene.pa_for(scene.kstat_template_kva, 0, kaslr);
    let cpu0_tick_pa = scene.pa_for(scene.tick_template_kva, 0, kaslr);
    scene.write_u64_at(cpu0_cpustat_pa + 2 * 8, 22_22u64); // CPUTIME_SYSTEM (slot 2)
    scene.write_u64_at(cpu0_kstat_pa, 7_007u64);
    scene.write_u32_at(cpu0_kstat_pa + 8, 70u32); // softirqs[0] = +8
    scene.write_u64_at(cpu0_tick_pa, 70_000u64);

    // CPU 1 at KASLR-shifted PAs (template + kaslr + 0x4000):
    let cpu1_cpustat_pa = scene.pa_for(scene.cpustat_template_kva, 1, kaslr);
    let cpu1_kstat_pa = scene.pa_for(scene.kstat_template_kva, 1, kaslr);
    let cpu1_tick_pa = scene.pa_for(scene.tick_template_kva, 1, kaslr);
    scene.write_u64_at(cpu1_cpustat_pa + 2 * 8, 33_33u64);
    scene.write_u64_at(cpu1_kstat_pa, 8_008u64);
    scene.write_u32_at(cpu1_kstat_pa + 8, 80u32);
    scene.write_u64_at(cpu1_tick_pa, 80_000u64);

    // CRITICAL: ALSO write distinct sentinel values at ALL three
    // link-time (kaslr=0) per-cpu slots — cpustat, kstat, AND
    // tick. A regression that drops kaslr_offset from ANY of the
    // three `per_cpu_kva` call sites in collect_per_cpu_time
    // would read the sentinel instead of the kaslr-shifted real
    // value. Without sentinels at all three sites, a regression
    // on kstat or tick alone would only surface as a zero-vs-real
    // mismatch (informative but less unambiguous than reading
    // back 0xDEAD_BEEF / 0xCAFE_BABE / 0xFEED_FACE explicitly).
    for cpu in 0..2 {
        let cpustat_link_pa = scene.pa_for(scene.cpustat_template_kva, cpu, 0);
        scene.write_u64_at(cpustat_link_pa + 2 * 8, 0xDEAD_BEEFu64);
        let kstat_link_pa = scene.pa_for(scene.kstat_template_kva, cpu, 0);
        scene.write_u64_at(kstat_link_pa, 0xCAFE_BABEu64);
        scene.write_u32_at(kstat_link_pa + 8, 0xFEEDu32); // softirqs[0]
        let tick_link_pa = scene.pa_for(scene.tick_template_kva, cpu, 0);
        scene.write_u64_at(tick_link_pa, 0xFEED_FACEu64);
    }

    let mem = unsafe {
        super::super::reader::GuestMem::new(scene.buf.as_mut_ptr(), scene.buf.len() as u64)
    };
    let capture = super::CpuTimeCapture {
        mem: &mem,
        offsets: &scene.offsets,
        kernel_cpustat_kva: scene.cpustat_template_kva,
        kstat_kva: scene.kstat_template_kva,
        tick_cpu_sched_kva: Some(scene.tick_template_kva),
        per_cpu_offsets: &scene.per_cpu_offsets,
        page_offset: PerCpuTimeScene::PAGE_OFFSET,
        kaslr_offset: kaslr,
    };
    let out = super::collect_per_cpu_time(&capture);
    assert_eq!(out.len(), 2);
    // The values read MUST be the KASLR-shifted-PA writes, NOT
    // the 0xDEAD_BEEF link-time-PA sentinel. A regression that
    // drops kaslr_offset would surface here.
    assert_eq!(out[0].cpustat_system_ns, 22_22);
    assert_eq!(out[0].irqs_sum, 7_007);
    assert_eq!(out[0].softirqs[0], 70);
    assert_eq!(out[0].iowait_sleeptime_ns, Some(70_000));
    assert_eq!(out[1].cpustat_system_ns, 33_33);
    assert_eq!(out[1].irqs_sum, 8_008);
    assert_eq!(out[1].softirqs[0], 80);
    assert_eq!(out[1].iowait_sleeptime_ns, Some(80_000));
}

/// `collect_per_cpu_time` skips the iowait_sleeptime read when
/// `tick_cpu_sched_kva` is None (CONFIG_NO_HZ_COMMON absent)
/// OR when `offsets.tick_sched_iowait_sleeptime` is None
/// (BTF lacks the field). Pins the skip path so a regression
/// that unconditionally reads the tick KVA would either panic
/// (None unwrap) or read garbage from a stale region.
#[test]
fn collect_per_cpu_time_no_tick_sched_skips_iowait() {
    let mut scene = PerCpuTimeScene::build(1, 0x4000);
    let kaslr = 0u64;
    let cpu0_cpustat_pa = scene.pa_for(scene.cpustat_template_kva, 0, kaslr);
    scene.write_u64_at(cpu0_cpustat_pa, 100u64);
    let mem = unsafe {
        super::super::reader::GuestMem::new(scene.buf.as_mut_ptr(), scene.buf.len() as u64)
    };
    let capture = super::CpuTimeCapture {
        mem: &mem,
        offsets: &scene.offsets,
        kernel_cpustat_kva: scene.cpustat_template_kva,
        kstat_kva: scene.kstat_template_kva,
        // tick_cpu_sched_kva = None covers the CONFIG_NO_HZ_COMMON-off
        // path.
        tick_cpu_sched_kva: None,
        per_cpu_offsets: &scene.per_cpu_offsets,
        page_offset: PerCpuTimeScene::PAGE_OFFSET,
        kaslr_offset: kaslr,
    };
    let out = super::collect_per_cpu_time(&capture);
    assert_eq!(out.len(), 1);
    assert_eq!(out[0].cpustat_user_ns, 100);
    assert_eq!(
        out[0].iowait_sleeptime_ns, None,
        "missing tick_cpu_sched_kva must surface as None, not zero",
    );
}

// ---------- identify_active_obj_from_struct_ops walker fallback ----------

/// Synthetic accessor seam for unit-testing the used_maps walker
/// path through `identify_active_obj_from_struct_ops`. Holds a
/// canned `(obj_name, used_map_kvas)` pair the walker would
/// otherwise derive from kernel memory. The target-free walker
/// shape (per `find_active_struct_ops_obj_no_target`) returns the
/// canned `ActiveObjMatch` whenever set; `None` simulates the
/// no-live-STRUCT_OPS-prog case.
struct TestProgAccessor {
    walker_result: Option<(String, Vec<u64>)>,
}

impl TestProgAccessor {
    fn returning(name: &str) -> Self {
        Self {
            walker_result: Some((name.to_string(), Vec::new())),
        }
    }

    fn returning_with_kvas(name: &str, used_map_kvas: Vec<u64>) -> Self {
        Self {
            walker_result: Some((name.to_string(), used_map_kvas)),
        }
    }

    fn none() -> Self {
        Self {
            walker_result: None,
        }
    }
}

impl super::super::bpf_prog::BpfProgAccessor for TestProgAccessor {
    fn struct_ops_progs(&self) -> Vec<super::super::bpf_prog::ProgVerifierStats> {
        Vec::new()
    }
    fn struct_ops_runtime_stats(
        &self,
        _per_cpu_offsets: &[u64],
    ) -> Vec<super::super::bpf_prog::ProgRuntimeStats> {
        Vec::new()
    }
    fn find_active_struct_ops_obj_no_target(
        &self,
        _map_offsets: &super::super::btf_offsets::BpfMapOffsets,
    ) -> Option<super::super::bpf_prog::ActiveObjMatch> {
        self.walker_result
            .as_ref()
            .map(|(name, kvas)| super::super::bpf_prog::ActiveObjMatch {
                obj_name: name.clone(),
                used_map_kvas: kvas.clone(),
            })
    }
}

fn synthetic_global_section_map(map_kva: u64, name: &str) -> super::super::bpf_map::BpfMapInfo {
    let (name_bytes, name_len) = name_from_str(name);
    super::super::bpf_map::BpfMapInfo {
        map_pa: 0,
        map_kva,
        name_bytes,
        name_len,
        map_type: super::super::bpf_map::BPF_MAP_TYPE_ARRAY,
        map_flags: 0,
        key_size: 0,
        value_size: 0,
        max_entries: 1,
        value_kva: None,
        btf_kva: 0,
        btf_value_type_id: 0,
        btf_vmlinux_value_type_id: 0,
        btf_key_type_id: 0,
    }
}

fn synthetic_struct_ops_map(
    map_kva: u64,
    name: &str,
    sched_kva: u64,
) -> super::super::bpf_map::BpfMapInfo {
    let (name_bytes, name_len) = name_from_str(name);
    super::super::bpf_map::BpfMapInfo {
        map_pa: 0,
        map_kva,
        name_bytes,
        name_len,
        map_type: super::super::bpf_map::BPF_MAP_TYPE_STRUCT_OPS,
        map_flags: 0,
        key_size: 0,
        value_size: 0,
        max_entries: 1,
        value_kva: Some(sched_kva),
        btf_kva: 0,
        btf_value_type_id: 0,
        btf_vmlinux_value_type_id: 0,
        btf_key_type_id: 0,
    }
}

/// Single libbpf-named struct_ops scenario: struct_ops map name
/// is `ktstr_ops` (no obj prefix), so the prefix cross-check at
/// the top of identify_active_obj_from_struct_ops fails; the
/// walker fallback fires and returns `ktstr`, which the
/// captured-maps cross-check validates against `ktstr.bss`.
#[test]
fn identify_active_obj_libbpf_named_single_scheduler_via_walker() {
    let sched_kva = 0xffff_0000_0000_a000;
    let struct_ops_map_kva = 0xffff_0000_0000_b000;
    let maps = vec![
        synthetic_struct_ops_map(struct_ops_map_kva, "ktstr_ops", sched_kva),
        synthetic_global_section_map(0xffff_0000_0000_c000, "ktstr.bss"),
    ];
    let map_offsets = super::super::btf_offsets::BpfMapOffsets::EMPTY;
    // Walker returns a realistic used_map_kvas set including the
    // matched struct_ops map and the sibling bss — Phase 2 requires
    // non-empty kvas to publish, so a synthetic empty set would
    // (correctly) be rejected by the helper's same-prefix-multi-copy
    // defense and never reach the cross-check this test exists for.
    let live_kvas = vec![struct_ops_map_kva, 0xffff_0000_0000_c000];
    let accessor = TestProgAccessor::returning_with_kvas("ktstr", live_kvas.clone());
    let result = super::identify_active_obj_from_struct_ops(&maps, Some((&accessor, &map_offsets)));
    assert_eq!(
        result,
        Some(("ktstr".to_string(), live_kvas)),
        "walker fallback must resolve libbpf-named struct_ops to the obj prefix \
         AND thread the used_map_kvas through to the caller",
    );
}

/// Two libbpf-named struct_ops scenario. The target-free walker
/// returns the live scheduler's obj prefix + used_map_kvas (here the
/// stubbed `TestProgAccessor` supplies them); the result names the
/// active (not staged) scheduler.
#[test]
fn identify_active_obj_libbpf_named_two_schedulers_picks_active() {
    let active_sched_kva = 0xffff_0000_0000_a000;
    let staged_sched_kva = 0xffff_0000_0000_e000;
    let active_struct_ops_kva = 0xffff_0000_0000_b000;
    let staged_struct_ops_kva = 0xffff_0000_0000_f000;
    let maps = vec![
        // Both struct_ops maps are libbpf-named (no obj prefix).
        synthetic_struct_ops_map(active_struct_ops_kva, "ktstr_ops", active_sched_kva),
        synthetic_struct_ops_map(staged_struct_ops_kva, "simple_ops", staged_sched_kva),
        synthetic_global_section_map(0xffff_0000_0000_c000, "ktstr.bss"),
        synthetic_global_section_map(0xffff_0000_0000_d000, "scx_simple.bss"),
    ];
    let map_offsets = super::super::btf_offsets::BpfMapOffsets::EMPTY;
    // Walker returns a realistic used_map_kvas including the active
    // struct_ops map and its sibling bss; Phase 2 requires non-empty
    // kvas to publish.
    let live_kvas = vec![active_struct_ops_kva, 0xffff_0000_0000_c000];
    let accessor = TestProgAccessor::returning_with_kvas("ktstr", live_kvas.clone());
    let result = super::identify_active_obj_from_struct_ops(&maps, Some((&accessor, &map_offsets)));
    assert_eq!(
        result,
        Some(("ktstr".to_string(), live_kvas)),
        "walker resolves the live scheduler's obj prefix + used_map_kvas (not the staged one)",
    );
}

/// Walker returns None (capture race: matched struct_ops map's
/// KVA appears in no prog's used_maps). identify_active_obj should
/// propagate None — callers fall back to Snapshot::active()'s
/// prefix-grouping heuristic.
#[test]
fn identify_active_obj_walker_returns_none_propagates_none() {
    let sched_kva = 0xffff_0000_0000_a000;
    let struct_ops_map_kva = 0xffff_0000_0000_b000;
    let maps = vec![
        synthetic_struct_ops_map(struct_ops_map_kva, "ktstr_ops", sched_kva),
        synthetic_global_section_map(0xffff_0000_0000_c000, "ktstr.bss"),
    ];
    let map_offsets = super::super::btf_offsets::BpfMapOffsets::EMPTY;
    let accessor = TestProgAccessor::none();
    let result = super::identify_active_obj_from_struct_ops(&maps, Some((&accessor, &map_offsets)));
    assert_eq!(
        result, None,
        "walker None propagates — heuristic fallback fires upstream",
    );
}

/// Captured-maps cross-check: walker returns a name that does
/// NOT match any `<name>.bss/.data/.rodata` map in the captured
/// maps[]. The validation check rejects the walker result;
/// identify_active_obj returns None rather than publishing a
/// bogus prefix.
#[test]
fn identify_active_obj_walker_result_validated_against_captured_maps() {
    let sched_kva = 0xffff_0000_0000_a000;
    let struct_ops_map_kva = 0xffff_0000_0000_b000;
    let maps = vec![
        synthetic_struct_ops_map(struct_ops_map_kva, "ktstr_ops", sched_kva),
        synthetic_global_section_map(0xffff_0000_0000_c000, "ktstr.bss"),
    ];
    let map_offsets = super::super::btf_offsets::BpfMapOffsets::EMPTY;
    // Walker returns "garbage" with a non-empty kvas list — no
    // `garbage.bss/.data/.rodata` in captured maps[], so the prefix
    // cross-check rejects. The kvas list is non-empty so we exercise
    // the validation rejection (not the unrelated empty-kvas
    // rejection that Phase 2 also enforces).
    let accessor = TestProgAccessor::returning_with_kvas("garbage", vec![struct_ops_map_kva]);
    let result = super::identify_active_obj_from_struct_ops(&maps, Some((&accessor, &map_offsets)));
    assert_eq!(
        result, None,
        "validation must reject walker result not backed by a captured global-section map",
    );
}

/// Libbpf-named struct_ops (`ktstr_ops`, no obj prefix) with
/// the walker tuple ENTIRELY None (not just `accessor-returns-None`).
/// Sibling of `identify_active_obj_walker_returns_none_propagates_none`,
/// which covers `Some(accessor-returns-None)`. Both flow through
/// the `prog_walker?` `?`-operator early-return, but they hit
/// different argument shapes — a regression to a future signature
/// change that mishandled the `Option<(_, _)>::None` case would
/// only be caught by this test.
#[test]
fn identify_active_obj_libbpf_named_without_walker_returns_none() {
    let sched_kva = 0xffff_0000_0000_a000;
    let struct_ops_map_kva = 0xffff_0000_0000_b000;
    let maps = vec![
        synthetic_struct_ops_map(struct_ops_map_kva, "ktstr_ops", sched_kva),
        synthetic_global_section_map(0xffff_0000_0000_c000, "ktstr.bss"),
    ];
    let result = super::identify_active_obj_from_struct_ops(&maps, None);
    assert_eq!(
        result, None,
        "libbpf-named struct_ops + walker tuple None must return None — \
         Phase 1 cannot resolve (no `ktstr_ops.bss` matches), Phase 2 \
         cannot run (prog_walker None short-circuits via `?`)",
    );
}

/// Without a prog walker, the existing prefix-grouping path
/// stays untouched: single-scheduler case with `<obj>.foo`
/// struct_ops name + `<obj>.bss` resolves correctly without
/// needing the walker.
#[test]
fn identify_active_obj_legacy_path_works_without_walker() {
    let sched_kva = 0xffff_0000_0000_a000;
    let maps = vec![
        synthetic_struct_ops_map(0xffff_0000_0000_b000, "ktstr.ops", sched_kva),
        synthetic_global_section_map(0xffff_0000_0000_c000, "ktstr.bss"),
    ];
    let result = super::identify_active_obj_from_struct_ops(&maps, None);
    assert_eq!(
        result,
        Some(("ktstr".to_string(), Vec::new())),
        "prefix cross-check resolves single-scheduler case without walker",
    );
}

/// Walker returns a name but EMPTY used_map_kvas. The primary-path
/// `!used_map_kvas.is_empty()` guard in
/// `identify_active_obj_from_struct_ops` rejects the empty whitelist
/// (an empty whitelist would defeat downstream KVA narrowing for
/// multi-copy cases) — control falls through to the prefix-grouping
/// fallback. With a single unambiguous `<obj>.bss/.data/.rodata`
/// sibling set, the fallback resolves cleanly.
#[test]
fn identify_active_obj_walker_with_empty_kvas_falls_back_to_prefix_grouping() {
    let sched_kva = 0xffff_0000_0000_a000;
    let struct_ops_map_kva = 0xffff_0000_0000_b000;
    let maps = vec![
        // `ktstr.ops` is prefix-resolvable. Walker returns "other"
        // with empty kvas → primary rejects → fallback iterates
        // `ktstr.ops` struct_ops, finds `ktstr.bss` unambiguous.
        synthetic_struct_ops_map(struct_ops_map_kva, "ktstr.ops", sched_kva),
        synthetic_global_section_map(0xffff_0000_0000_c000, "ktstr.bss"),
    ];
    let map_offsets = super::super::btf_offsets::BpfMapOffsets::EMPTY;
    // Walker returns "other" with EMPTY kvas — primary path's
    // `!is_empty()` guard rejects, fallback fires and returns the
    // prefix-grouped "ktstr" result.
    let accessor = TestProgAccessor::returning("other");
    let result = super::identify_active_obj_from_struct_ops(&maps, Some((&accessor, &map_offsets)));
    assert_eq!(
        result,
        Some(("ktstr".to_string(), Vec::new())),
        "walker-with-empty-kvas falls through to prefix-grouping fallback",
    );
}

/// PRIMARY-path multi-copy case: walker returns a name + non-empty
/// kvas, AND the captured `maps[]` has TWO `<prefix>.bss` copies
/// (the same-binary swap-window scenario). Documents the intended
/// behavior: the walker's kva set IS the disambiguator at this
/// layer, so the primary-path cross-check at
/// `count_global_sections_for_prefix > 0` ADMITS the multi-copy
/// case — the downstream `Snapshot::active` KVA filter narrows
/// using the walker's `used_map_kvas` whitelist. The walker MUST
/// have returned the live scheduler's kvas only (the OLD prog's
/// used_maps reference the OLD bss kva; that prog was freed
/// post-detach, so the walker only sees the NEW prog's kvas).
#[test]
fn identify_active_obj_walker_publishes_multi_copy_kvas_for_downstream_filter() {
    let active_sched_kva = 0xffff_0000_0000_a000;
    let active_struct_ops_kva = 0xffff_0000_0000_b100;
    let active_bss_kva = 0xffff_0000_0000_c100;
    let stale_bss_kva = 0xffff_0000_0000_c200;
    let maps = vec![
        synthetic_struct_ops_map(active_struct_ops_kva, "bpf_bpf.ops", active_sched_kva),
        // TWO `bpf_bpf.bss` copies (same-binary swap window).
        synthetic_global_section_map(active_bss_kva, "bpf_bpf.bss"),
        synthetic_global_section_map(stale_bss_kva, "bpf_bpf.bss"),
    ];
    let map_offsets = super::super::btf_offsets::BpfMapOffsets::EMPTY;
    // Walker returns ONLY the active scheduler's kvas (the OLD
    // prog is gone from prog_idr, so the walker has no way to
    // include the stale bss).
    let live_kvas = vec![active_struct_ops_kva, active_bss_kva];
    let accessor = TestProgAccessor::returning_with_kvas("bpf_bpf", live_kvas.clone());
    let result = super::identify_active_obj_from_struct_ops(&maps, Some((&accessor, &map_offsets)));
    assert_eq!(
        result,
        Some(("bpf_bpf".to_string(), live_kvas)),
        "primary path admits multi-copy when walker publishes a \
         non-empty kvas whitelist; downstream Snapshot::active narrows",
    );
}

/// FALLBACK iterates ALL struct_ops maps in `maps[]` order and
/// returns the FIRST whose prefix has unambiguous global-section
/// siblings. When two distinct schedulers coexist with NO walker
/// (e.g. a future test that loads two non-sched_ext struct_ops
/// objects, OR a misconfigured guest with no `owned_prog_accessor`
/// rebuild), the fallback picks the first struct_ops in iteration
/// order regardless of which is "live". Pins this as the
/// documented behavior — the consumer's `Snapshot::active`
/// multi-obj fallback (its `NoActiveScheduler` return in
/// `scenario::snapshot::view`) catches the mis-pick
/// case and surfaces a NoActiveScheduler diagnostic with both
/// prefixes named.
#[test]
fn identify_active_obj_fallback_with_two_distinct_schedulers_returns_first_iteration_order() {
    let alpha_sched_kva = 0xffff_0000_0000_a000;
    let alpha_struct_ops_kva = 0xffff_0000_0000_b000;
    let beta_sched_kva = 0xffff_0000_0000_d000;
    let beta_struct_ops_kva = 0xffff_0000_0000_e000;
    let maps = vec![
        synthetic_struct_ops_map(alpha_struct_ops_kva, "alpha.ops", alpha_sched_kva),
        synthetic_global_section_map(0xffff_0000_0000_c000, "alpha.bss"),
        synthetic_struct_ops_map(beta_struct_ops_kva, "beta.ops", beta_sched_kva),
        synthetic_global_section_map(0xffff_0000_0000_f000, "beta.bss"),
    ];
    // Either scheduler could be the "live" one per scx_sched_state;
    // walker is None so primary-path is skipped. Fallback picks
    // the FIRST struct_ops in maps[] iteration order.
    let result = super::identify_active_obj_from_struct_ops(&maps, None);
    assert_eq!(
        result,
        Some(("alpha".to_string(), Vec::new())),
        "fallback returns first struct_ops in iteration order — \
         known limitation. Consumer's Snapshot::active multi-obj \
         fallback catches the mis-pick case downstream",
    );
}

/// Same-binary `Op::ReplaceScheduler` swap window: two scheduler
/// instances built from the same binary coexist, so the captured
/// `maps[]` carries TWO `<prefix>.bss` maps with identical names
/// at distinct kernel KVAs (plus paired `<prefix>.data` /
/// `<prefix>.rodata` copies). The prefix-only Phase 1 cross-check
/// cannot pick the live scheduler — it would return an empty
/// `active_map_kvas` whitelist and the consumer's downstream
/// `live_var()` would then surface
/// [`super::super::super::scenario::snapshot::SnapshotError::AmbiguousVar`]
/// (the bug from the scx_mitosis swap test:
/// `AmbiguousVar { found_in: ["bpf_bpf.bss", "bpf_bpf.bss"] }`).
/// Phase 1 must detect the multi-copy case and fall through to
/// Phase 2 so the walker publishes a disambiguating KVA whitelist.
#[test]
fn identify_active_obj_same_binary_swap_falls_through_to_walker_for_kva_disambig() {
    let active_sched_kva = 0xffff_0000_0000_a000;
    let old_struct_ops_kva = 0xffff_0000_0000_b000;
    let active_struct_ops_kva = 0xffff_0000_0000_b800;
    let active_bss_kva = 0xffff_0000_0000_c000;
    let active_data_kva = 0xffff_0000_0000_c200;
    let old_bss_kva = 0xffff_0000_0000_d000;
    let old_data_kva = 0xffff_0000_0000_d200;
    let maps = vec![
        // Old scheduler's struct_ops map (sched_kva different from
        // active — see synthetic value below).
        synthetic_struct_ops_map(old_struct_ops_kva, "bpf_bpf.ops", 0xdead_0000_0000_e000),
        // Active scheduler's struct_ops map points at the live
        // *scx_root via value_kva == sched_kva.
        synthetic_struct_ops_map(active_struct_ops_kva, "bpf_bpf.ops", active_sched_kva),
        // TWO `bpf_bpf.bss` copies (one per scheduler instance).
        // Identical names, distinct KVAs.
        synthetic_global_section_map(active_bss_kva, "bpf_bpf.bss"),
        synthetic_global_section_map(old_bss_kva, "bpf_bpf.bss"),
        synthetic_global_section_map(active_data_kva, "bpf_bpf.data"),
        synthetic_global_section_map(old_data_kva, "bpf_bpf.data"),
    ];
    let map_offsets = super::super::btf_offsets::BpfMapOffsets::EMPTY;
    // Walker resolves the live scheduler via the prog whose
    // used_maps contains active_struct_ops_kva. Synthetic walker
    // returns the live scheduler's bss + data KVAs as the
    // whitelist (the real walker derives these from the prog's
    // aux->used_maps array).
    let live_kvas = vec![active_struct_ops_kva, active_bss_kva, active_data_kva];
    let accessor = TestProgAccessor::returning_with_kvas("bpf_bpf", live_kvas.clone());
    let result = super::identify_active_obj_from_struct_ops(&maps, Some((&accessor, &map_offsets)));
    assert_eq!(
        result,
        Some(("bpf_bpf".to_string(), live_kvas)),
        "multi-copy same-prefix bss must trigger Phase 2 walker so the \
         live scheduler's KVA whitelist is published — empty whitelist + \
         ambiguous prefix is the swap-window AmbiguousVar regression",
    );
}

/// Same-binary swap window but the prog accessor is unavailable
/// (transient between scheduler kill and accessor-init worker
/// re-publish): Phase 1 detects ambiguity, Phase 2 cannot run.
/// Helper returns `None` rather than short-circuiting to
/// `Some((prefix, vec![]))` so the produced
/// `FailureDumpReport.active_obj_name` is `None` rather than a
/// populated-but-undisambiguated prefix.
///
/// **What the consumer does with `None`.** [`Snapshot::active`]
/// also runs its own per-(prefix, section) count over the captured
/// `maps[]`. When `active_obj_name` is `None` AND any section type
/// has more than one copy for the chosen prefix, the consumer
/// returns `SnapshotError::NoActiveScheduler` with a reason naming
/// the multi-copy section — surfacing the structural cause rather
/// than silently admitting both bss copies (which would re-trigger
/// the `AmbiguousVar` regression downstream). Together the two
/// layers form a defense pair: this helper's `None` signals "I
/// could not disambiguate"; the consumer's per-section count
/// translates that into an actionable `NoActiveScheduler` for the
/// test author.
#[test]
fn identify_active_obj_same_binary_swap_without_walker_returns_none() {
    let active_sched_kva = 0xffff_0000_0000_a000;
    let maps = vec![
        synthetic_struct_ops_map(0xffff_0000_0000_b000, "bpf_bpf.ops", active_sched_kva),
        synthetic_global_section_map(0xffff_0000_0000_c000, "bpf_bpf.bss"),
        synthetic_global_section_map(0xffff_0000_0000_d000, "bpf_bpf.bss"),
    ];
    let result = super::identify_active_obj_from_struct_ops(&maps, None);
    assert_eq!(
        result, None,
        "ambiguous prefix without walker must NOT short-circuit to \
         (prefix, vec![]) — helper's None is the upstream signal that \
         pairs with `Snapshot::active`'s per-section count to surface \
         NoActiveScheduler instead of admitting both copies",
    );
}

/// Ambiguity in the `.data` section (rather than `.bss`) also
/// forces Phase 2. A scheduler with one `<prefix>.bss` plus two
/// `<prefix>.data` copies (the live `.data` of the new instance
/// plus a stale `.data` from the dying instance during the
/// `Op::ReplaceScheduler` swap window) must still trigger the
/// walker fallback even though the `.bss` count alone is
/// unambiguous.
#[test]
fn identify_active_obj_ambiguous_data_section_forces_walker() {
    let active_sched_kva = 0xffff_0000_0000_a000;
    let active_struct_ops_kva = 0xffff_0000_0000_b000;
    let live_data_kva = 0xffff_0000_0000_c000;
    let maps = vec![
        synthetic_struct_ops_map(active_struct_ops_kva, "bpf_bpf.ops", active_sched_kva),
        // One `bpf_bpf.bss` (unambiguous in this section), but TWO
        // `bpf_bpf.data` (ambiguous): forces Phase 2.
        synthetic_global_section_map(0xffff_0000_0000_c100, "bpf_bpf.bss"),
        synthetic_global_section_map(live_data_kva, "bpf_bpf.data"),
        synthetic_global_section_map(0xffff_0000_0000_d000, "bpf_bpf.data"),
    ];
    let map_offsets = super::super::btf_offsets::BpfMapOffsets::EMPTY;
    let live_kvas = vec![active_struct_ops_kva, live_data_kva];
    let accessor = TestProgAccessor::returning_with_kvas("bpf_bpf", live_kvas.clone());
    let result = super::identify_active_obj_from_struct_ops(&maps, Some((&accessor, &map_offsets)));
    assert_eq!(
        result,
        Some(("bpf_bpf".to_string(), live_kvas)),
        ".data ambiguity must force Phase 2 just like .bss ambiguity",
    );
}

/// `.rodata` ambiguity symmetrically forces Phase 2 — pins that
/// the third section type is treated the same way as `.bss` and
/// `.data`.
#[test]
fn identify_active_obj_ambiguous_rodata_section_forces_walker() {
    let active_sched_kva = 0xffff_0000_0000_a000;
    let active_struct_ops_kva = 0xffff_0000_0000_b000;
    let live_rodata_kva = 0xffff_0000_0000_c000;
    let maps = vec![
        synthetic_struct_ops_map(active_struct_ops_kva, "bpf_bpf.ops", active_sched_kva),
        synthetic_global_section_map(0xffff_0000_0000_c100, "bpf_bpf.bss"),
        synthetic_global_section_map(live_rodata_kva, "bpf_bpf.rodata"),
        synthetic_global_section_map(0xffff_0000_0000_d000, "bpf_bpf.rodata"),
    ];
    let map_offsets = super::super::btf_offsets::BpfMapOffsets::EMPTY;
    let live_kvas = vec![active_struct_ops_kva, live_rodata_kva];
    let accessor = TestProgAccessor::returning_with_kvas("bpf_bpf", live_kvas.clone());
    let result = super::identify_active_obj_from_struct_ops(&maps, Some((&accessor, &map_offsets)));
    assert_eq!(
        result,
        Some(("bpf_bpf".to_string(), live_kvas)),
        ".rodata ambiguity must force Phase 2 just like .bss / .data",
    );
}

/// Pins the strict full-name equality of the section counter
/// against a regression to `starts_with` matching. A single
/// scheduler with a hypothetical `<prefix>.bss.shared` map (in
/// addition to its canonical `<prefix>.bss`) would, under
/// `starts_with`, double-count and force Phase 2 spuriously.
/// Under strict equality the `.shared` map is ignored (the
/// consumer's classifier and the walker's `strip_suffix(".bss")`
/// both reject it too), so the single canonical `<prefix>.bss`
/// keeps bss_count = 1 and Phase 1 short-circuits correctly.
///
/// The `.bss.shared` shape does not appear in mainstream libbpf
/// output today; the test is a guard for cross-site classifier
/// drift, not a real production scenario.
#[test]
fn identify_active_obj_section_counter_rejects_non_canonical_names() {
    let sched_kva = 0xffff_0000_0000_a000;
    let maps = vec![
        synthetic_struct_ops_map(0xffff_0000_0000_b000, "ktstr.ops", sched_kva),
        synthetic_global_section_map(0xffff_0000_0000_c000, "ktstr.bss"),
        // Non-canonical: starts_with("ktstr.bss") would count this,
        // strict equality must not.
        synthetic_global_section_map(0xffff_0000_0000_d000, "ktstr.bss.shared"),
    ];
    let result = super::identify_active_obj_from_struct_ops(&maps, None);
    assert_eq!(
        result,
        Some(("ktstr".to_string(), Vec::new())),
        "section counter must use full-name equality — a `<prefix>.bss.shared` \
         map must not inflate the bss count or force Phase 2 spuriously",
    );
}

/// Pins that the walker's `used_map_kvas` thread through to the
/// returned tuple verbatim — a regression that dropped the KVA
/// vector in Phase 2's return arm would surface as empty
/// disambiguation post-swap. The earlier walker-named tests
/// returned the (then-defaulted) empty whitelist, so this case
/// was not covered.
#[test]
fn identify_active_obj_walker_used_map_kvas_published_to_caller() {
    let sched_kva = 0xffff_0000_0000_a000;
    let struct_ops_map_kva = 0xffff_0000_0000_b000;
    let maps = vec![
        // libbpf-named struct_ops (no obj prefix) so Phase 2
        // fires via the libbpf path rather than the multi-copy
        // path.
        synthetic_struct_ops_map(struct_ops_map_kva, "ktstr_ops", sched_kva),
        synthetic_global_section_map(0xffff_0000_0000_c000, "ktstr.bss"),
    ];
    let map_offsets = super::super::btf_offsets::BpfMapOffsets::EMPTY;
    let kvas = vec![0xdead_beef_0000_0001, 0xdead_beef_0000_0002];
    let accessor = TestProgAccessor::returning_with_kvas("ktstr", kvas.clone());
    let result = super::identify_active_obj_from_struct_ops(&maps, Some((&accessor, &map_offsets)));
    assert_eq!(
        result,
        Some(("ktstr".to_string(), kvas)),
        "walker's used_map_kvas must thread through to the caller verbatim",
    );
}

/// Round-trip u64 KVAs at the top of the address space through
/// `serde_json` and back. Kernel KASLR-slid addresses set the high
/// bits (typical `0xffff_8000_*` range); a regression to a serde
/// feature flag that lossily coerced large u64 to f64 would
/// truncate the bottom ~11 bits. Pins the bit-exact round-trip.
#[test]
fn failure_dump_map_and_report_round_trip_high_u64_kvas() {
    let high_kva: u64 = 0xFFFF_FFFF_FFFF_FFFF;
    let kaslr_slid: u64 = 0xFFFF_8000_1234_5678;
    let other_kva: u64 = 0xFFFF_8888_AAAA_BBBB;

    let m = FailureDumpMap {
        name: "scx_test.bss".into(),
        map_kva: high_kva,
        map_type: 2,
        ..Default::default()
    };
    let m_json = serde_json::to_string(&m).expect("serialize map");
    let m_back: FailureDumpMap = serde_json::from_str(&m_json).expect("deserialize map");
    assert_eq!(
        m_back.map_kva, high_kva,
        "FailureDumpMap.map_kva must round-trip bit-exact across the top of the u64 range",
    );

    let r = FailureDumpReport {
        active_map_kvas: vec![high_kva, kaslr_slid, other_kva],
        ..Default::default()
    };
    let r_json = serde_json::to_string(&r).expect("serialize report");
    let r_back: FailureDumpReport = serde_json::from_str(&r_json).expect("deserialize report");
    assert_eq!(
        r_back.active_map_kvas,
        vec![high_kva, kaslr_slid, other_kva],
        "FailureDumpReport.active_map_kvas must round-trip every u64 bit-exact, \
         preserving order and top-bit-set addresses",
    );
}

/// The kernel never allocates `struct bpf_map` at virtual address
/// 0 (vmalloc/slab never returns NULL on success). A real capture
/// reading `info.map_kva` therefore MUST surface a non-zero value;
/// a zero on the production path indicates either (a) a backend
/// stub or (b) a null-KVA leak in the BPF walker, both of which
/// the consumer-side `Snapshot::active` would silently treat as
/// "no kernel identity" and fall through to obj-prefix matching.
/// This test pins the zero-KVA sentinel semantic so a regression
/// where the walker emits 0 for a real map surfaces immediately.
#[test]
fn failure_dump_map_zero_kva_is_no_identity_sentinel_not_real_capture() {
    // Default-constructed (synthetic test fixture path): map_kva = 0
    // means "no identity recorded".
    let synthetic = FailureDumpMap::default();
    assert_eq!(
        synthetic.map_kva, 0,
        "Default::default() leaves map_kva = 0 as the no-identity sentinel; \
         synthetic test fixtures rely on this to skip the field via serde",
    );
    // The sentinel survives JSON round-trip: absent in JSON →
    // deserializes back to 0 (via skip_serializing_if + default).
    let json = serde_json::to_string(&synthetic).expect("serialize");
    assert!(
        !json.contains("map_kva"),
        "zero map_kva must be omitted from serialized JSON (skip_serializing_if=is_zero_u64); \
         got {json}",
    );
    let back: FailureDumpMap = serde_json::from_str(&json).expect("deserialize");
    assert_eq!(
        back.map_kva, 0,
        "absent-in-JSON deserializes back to 0 (the sentinel)"
    );
}

// ---- try_write_entry_table + FailureDumpPercpuEntry Display ------
//
// Targets the table-layout decision in `try_write_entry_table` (via
// the `FailureDumpMap` Display path) and the four-way branch in
// `FailureDumpPercpuEntry::Display` (simple list, >64-CPU dedup-skip,
// template/varying, and contiguous-range grouping). Every fixture is
// a synthesized `RenderedValue` tree — no VM boot — and every
// assertion pins the exact rendered substring the branch produces.

/// Construct a `FailureDumpMap` carrying `entries` as its only
/// populated content. All non-entry collections are empty so the
/// rendered output is exactly the `map ...` header line followed by
/// whatever `try_write_entry_table` (or the per-entry fallback)
/// emits — no array / percpu / arena / ringbuf trailers to confuse
/// substring assertions.
fn map_with_entries(name: &str, entries: Vec<FailureDumpEntry>) -> FailureDumpMap {
    FailureDumpMap {
        name: name.into(),
        map_kva: 0,
        map_type: BPF_MAP_TYPE_HASH,
        value_size: 8,
        max_entries: 64,
        value: None,
        entries,
        array_entries: Vec::new(),
        percpu_entries: Vec::new(),
        percpu_hash_entries: Vec::new(),
        arena: None,
        ringbuf: None,
        stack_trace: None,
        fd_array: None,
        error: None,
    }
}

/// `make_small_struct`-shaped entry: both sides are same-shape
/// inline-scalar structs with no payload, so a batch of these is the
/// homogeneous-table-eligible case.
fn table_entry(
    key_ty: &str,
    key_fields: &[(&str, u64)],
    val_ty: &str,
    val_fields: &[(&str, u64)],
) -> FailureDumpEntry {
    FailureDumpEntry {
        key: Some(make_small_struct(key_ty, key_fields)),
        key_hex: "00".into(),
        value: Some(make_small_struct(val_ty, val_fields)),
        value_hex: "00".into(),
        payload: None,
    }
}

#[test]
fn try_write_entry_table_homogeneous_exact_layout() {
    // Two homogeneous entries with single-column key + value structs
    // exercise the full width-measurement + right-alignment path.
    // The body following the `map ...` header is, byte-for-byte:
    //   "\n  id |  n"   (header: key name width 2, value name width 2)
    //   "\n   1 |  5"   (row 0, both cells right-aligned to width 2)
    //   "\n  10 | 50"   (row 1)
    // Column width 2 comes from: key header "id"=2 vs cells "1"/"10";
    // value header "n"=1 vs cells "5"/"50"=2 ⇒ max 2 each.
    let m = map_with_entries(
        "kc-table",
        vec![
            table_entry("kc", &[("id", 1)], "vc", &[("n", 5)]),
            table_entry("kc", &[("id", 10)], "vc", &[("n", 50)]),
        ],
    );
    let out = format!("{m}");
    let body = out
        .strip_prefix("map kc-table (type=hash, value_size=8, max_entries=64)")
        .expect("map header prefix must match exactly");
    assert_eq!(
        body, "\n  id |  n\n   1 |  5\n  10 | 50",
        "table body must be the exact right-aligned grid: {out:?}",
    );
    // The per-entry block form must NOT appear — the table replaced it.
    assert!(
        !out.contains("entry: key="),
        "homogeneous batch must render as a table, not per-entry: {out}",
    );
}

#[test]
fn try_write_entry_table_two_entries_meets_minimum() {
    // TABLE_MIN_ENTRIES is 2: a 2-entry homogeneous batch qualifies
    // (boundary). One fewer (covered by the existing single-entry
    // test) would not. This pins the lower boundary as table-eligible.
    let m = map_with_entries(
        "two",
        vec![
            table_entry("k", &[("a", 1)], "v", &[("b", 2)]),
            table_entry("k", &[("a", 3)], "v", &[("b", 4)]),
        ],
    );
    let out = format!("{m}");
    assert!(out.contains(" | "), "2 entries must form a table: {out}");
    assert!(
        !out.contains("entry: key="),
        "2-entry table must not fall back to per-entry: {out}",
    );
}

#[test]
fn try_write_entry_table_rejects_payload_present() {
    // A single payload-bearing entry disqualifies the whole batch —
    // the typed payload renders in a `.data` block the table can't
    // carry, so every entry falls back to per-entry block form and
    // the surviving payload still surfaces via `.data`.
    let mut e0 = table_entry("k", &[("a", 1)], "v", &[("b", 2)]);
    e0.payload = Some(RenderedValue::Uint {
        bits: 64,
        value: 0xDEAD,
    });
    let m = map_with_entries(
        "with-payload",
        vec![e0, table_entry("k", &[("a", 3)], "v", &[("b", 4)])],
    );
    let out = format!("{m}");
    assert!(
        !out.contains(" | "),
        "payload-bearing batch must NOT render as a table: {out}",
    );
    assert!(
        out.contains("entry: key="),
        "payload-bearing batch must use per-entry form: {out}",
    );
    assert!(
        out.contains("\n  .data "),
        "the payload must still surface via .data: {out}",
    );
    assert!(
        out.contains("57005"), // 0xDEAD in decimal
        "the payload value must render: {out}",
    );
}

#[test]
fn try_write_entry_table_rejects_heterogeneous_value_type() {
    // Same key type across both entries but DIFFERENT value type
    // names ⇒ not homogeneous ⇒ no table. (The existing
    // heterogeneous test varies the key type; this pins the value
    // side of the type_name agreement check.)
    let m = map_with_entries(
        "het-val",
        vec![
            table_entry("k", &[("a", 1)], "v1", &[("b", 2)]),
            table_entry("k", &[("a", 3)], "v2", &[("b", 4)]),
        ],
    );
    let out = format!("{m}");
    assert!(
        !out.contains(" | "),
        "differing value type_name must reject the table: {out}",
    );
    assert!(
        out.contains("entry: key="),
        "heterogeneous value type must use per-entry form: {out}",
    );
}

/// Per-CPU struct slot with the given inline-scalar fields, for
/// `FailureDumpPercpuEntry` Display tests.
fn percpu_struct(ty: &str, fields: &[(&str, u64)]) -> Option<RenderedValue> {
    Some(make_small_struct(ty, fields))
}

#[test]
fn percpu_entry_display_template_varying_branch() {
    // 3 CPUs, each a UNIQUE struct (so every group has exactly one
    // CPU and groups.len()==3) where only `count` varies and
    // `weight` is identical (1024). This drives the template branch:
    // common fields rendered once, varying field as a per-CPU table.
    let entry = FailureDumpPercpuEntry {
        key: 0,
        per_cpu: vec![
            percpu_struct("ctx", &[("weight", 1024), ("count", 10)]),
            percpu_struct("ctx", &[("weight", 1024), ("count", 20)]),
            percpu_struct("ctx", &[("weight", 1024), ("count", 30)]),
        ],
    };
    let out = format!("{entry}");
    assert!(
        out.contains("key 0: struct ctx (3 CPUs)"),
        "header must name the struct + CPU count: {out}",
    );
    // Common (non-varying, non-zero) field shown once under `common:`.
    assert!(
        out.contains("\n  common:"),
        "common section header missing: {out}",
    );
    assert!(
        out.contains("\n    weight: 1024"),
        "identical field rendered once in common: {out}",
    );
    // `weight` must appear EXACTLY once — proving it was hoisted to
    // common and NOT repeated per CPU (the whole point of the branch).
    assert_eq!(
        out.matches("weight").count(),
        1,
        "identical field must not repeat per CPU: {out}",
    );
    // Varying field rendered as a per-CPU table with one column.
    assert!(
        out.contains("\n  per-cpu:"),
        "per-cpu table header missing: {out}",
    );
    assert!(
        out.contains("\n    cpu | count"),
        "per-cpu table column header missing: {out}",
    );
    assert!(out.contains("| 10"), "cpu 0 varying value: {out}");
    assert!(out.contains("| 20"), "cpu 1 varying value: {out}");
    assert!(out.contains("| 30"), "cpu 2 varying value: {out}");
    // The fallback per-group `cpus ...:` lines must NOT appear — the
    // template branch returns before the fallback loop.
    assert!(
        !out.contains("cpu 0:"),
        "template branch must not emit per-group rows: {out}",
    );
}

#[test]
fn percpu_entry_display_template_zero_common_field_suppressed() {
    // A common field that is zero is suppressed from `common:` (the
    // `is_zero` skip), while the non-zero common field survives and
    // the varying field still tables. Pins the zero-suppression that
    // sits inside the template branch.
    let entry = FailureDumpPercpuEntry {
        key: 7,
        per_cpu: vec![
            percpu_struct("s", &[("live", 5), ("dead", 0), ("v", 1)]),
            percpu_struct("s", &[("live", 5), ("dead", 0), ("v", 2)]),
            percpu_struct("s", &[("live", 5), ("dead", 0), ("v", 3)]),
        ],
    };
    let out = format!("{entry}");
    assert!(
        out.contains("\n    live: 5"),
        "non-zero common field must render: {out}",
    );
    assert!(
        !out.contains("dead"),
        "zero common field must be suppressed silently: {out}",
    );
    assert!(
        out.contains("\n    cpu | v"),
        "varying field tables under per-cpu: {out}",
    );
}

#[test]
fn percpu_entry_display_contiguous_range_grouping() {
    // 4 CPUs in 2 groups (cpus 0,1 share one struct; cpus 2,3 share
    // another). groups.len()==2 < 3 so the template branch is skipped
    // and the fallback runs. Each >1-CPU contiguous group collapses
    // to `cpus FIRST-LAST`. The contiguity test is the `windows(2)`
    // adjacent-difference check.
    let a = percpu_struct("g", &[("v", 100)]);
    let b = percpu_struct("g", &[("v", 200)]);
    let entry = FailureDumpPercpuEntry {
        key: 1,
        per_cpu: vec![a.clone(), a, b.clone(), b],
    };
    let out = format!("{entry}");
    assert!(
        out.contains("key 1: struct g (4 CPUs)"),
        "header must name struct + CPU count: {out}",
    );
    assert!(
        out.contains("\n  cpus 0-1: "),
        "contiguous low group must render as a range: {out}",
    );
    assert!(
        out.contains("\n  cpus 2-3: "),
        "contiguous high group must render as a range: {out}",
    );
    // Range form, not the debug-list `cpus [0, 1]` form.
    assert!(
        !out.contains("cpus ["),
        "contiguous CPUs must use the range form, not the list form: {out}",
    );
}

#[test]
fn percpu_entry_display_noncontiguous_group_uses_list_form() {
    // A group whose CPUs are NOT adjacent (0 and 2, with CPU 1
    // holding a different value) must fall through the contiguity
    // check to the debug-list `cpus {:?}` form. Pins the
    // `windows(2)` branch's false arm against the contiguous case
    // above. 3 distinct-content CPUs would trip the template branch,
    // so we use 4 CPUs forming 2 groups, one of which is split.
    let a = percpu_struct("g", &[("v", 7)]);
    let b = percpu_struct("g", &[("v", 9)]);
    let entry = FailureDumpPercpuEntry {
        key: 2,
        // groups: v=7 → cpus [0, 2]; v=9 → cpus [1, 3].
        per_cpu: vec![a.clone(), b.clone(), a, b],
    };
    let out = format!("{entry}");
    assert!(
        out.contains("cpus [0, 2]"),
        "non-contiguous group must use the debug-list form: {out}",
    );
    assert!(
        !out.contains("cpus 0-2"),
        "non-contiguous group must NOT use the range form: {out}",
    );
}

#[test]
fn percpu_entry_display_over_64_cpus_skips_dedup() {
    // 65 CPUs (> PERCPU_DEDUP_CPU_LIMIT = 64) bypasses the O(n²)
    // dedup pass and emits one `cpu N:` row per CPU even though every
    // value is identical (which below the threshold would collapse to
    // a single `all CPUs:` group). Pins the scale-guard branch.
    let v = percpu_struct("c", &[("x", 1)]);
    let entry = FailureDumpPercpuEntry {
        key: 3,
        per_cpu: (0..65).map(|_| v.clone()).collect(),
    };
    let out = format!("{entry}");
    assert!(
        out.contains("key 3: struct c (65 CPUs)"),
        "header must report 65 CPUs: {out}",
    );
    // One row per CPU — first, a middle, and last all present.
    assert!(out.contains("\n  cpu 0: "), "cpu 0 row missing: {out}");
    assert!(out.contains("\n  cpu 64: "), "cpu 64 row missing: {out}");
    // Exactly 65 `cpu N: ` rows (each on its own `\n  cpu ` line).
    assert_eq!(
        out.matches("\n  cpu ").count(),
        65,
        "every CPU must get its own row above the dedup threshold: {out}",
    );
    // The dedup grouping (`all CPUs:`) and the template form must NOT
    // appear — both are skipped above the threshold.
    assert!(
        !out.contains("all CPUs"),
        "above-threshold render must not dedup into an all-CPUs group: {out}",
    );
    assert!(
        !out.contains("common:"),
        "above-threshold render must not use the template form: {out}",
    );
}

#[test]
fn percpu_entry_display_all_identical_collapses_to_all_cpus() {
    // Below the threshold, all-identical struct slots collapse to a
    // single `all CPUs:` group (the `cpus.len() == n_cpus` arm of the
    // fallback). Contrast with the >64 test above, where the same
    // all-identical input is forced to one row per CPU. 4 CPUs keeps
    // groups.len()==1, so neither template (>=3 groups) nor range
    // grouping applies.
    let v = percpu_struct("c", &[("x", 42)]);
    let entry = FailureDumpPercpuEntry {
        key: 4,
        per_cpu: vec![v.clone(), v.clone(), v.clone(), v],
    };
    let out = format!("{entry}");
    assert!(
        out.contains("key 4: struct c (4 CPUs)"),
        "header must report 4 CPUs: {out}",
    );
    assert!(
        out.contains("\n  all CPUs: "),
        "all-identical slots collapse to a single all-CPUs group: {out}",
    );
    assert!(
        !out.contains("\n  cpu 0:"),
        "the collapsed form must not emit per-CPU rows: {out}",
    );
}

#[test]
fn percpu_entry_display_fallback_marks_unmapped_cpus() {
    // A mix of mapped structs (2 groups, so template skipped) plus an
    // unmapped CPU: the fallback emits each group then a trailing
    // `cpus [..]: <unmapped>` line listing the None slots. Pins the
    // unmapped-tail rendering in the fallback path.
    let a = percpu_struct("g", &[("v", 1)]);
    let b = percpu_struct("g", &[("v", 2)]);
    let entry = FailureDumpPercpuEntry {
        key: 5,
        per_cpu: vec![a, b, None],
    };
    let out = format!("{entry}");
    assert!(
        out.contains("\n  cpus [2]: <unmapped>"),
        "unmapped CPU must be listed in the fallback tail: {out}",
    );
}

// -- count_global_sections_for_prefix ------------------------------
//
// The classifier is only exercised transitively through
// `identify_active_obj_from_struct_ops` today; the per-section count
// math and the STRUCT_OPS-skip arm are never asserted in isolation.

/// Direct count: full-name equality classifies each section, the
/// STRUCT_OPS map is skipped, and a `<prefix>.bss.shared` map is NOT
/// counted as a `.bss` (the comparison is `n == "<prefix>.bss"`, not
/// a prefix match). A `sched.bss` STRUCT_OPS map must NOT inflate the
/// `.bss` total — only the ARRAY `sched.bss` counts.
#[test]
fn count_global_sections_for_prefix_skips_struct_ops_and_uses_full_name_equality() {
    let maps = vec![
        synthetic_global_section_map(0xa000, "sched.bss"),
        synthetic_global_section_map(0xb000, "sched.data"),
        synthetic_global_section_map(0xc000, "sched.bss.shared"),
        synthetic_struct_ops_map(0xd000, "sched.bss", 0xe000),
    ];
    // (bss, data, rodata): one real `.bss` (struct_ops `sched.bss`
    // excluded; `sched.bss.shared` is not `sched.bss`), one `.data`,
    // zero `.rodata`.
    assert_eq!(
        super::count_global_sections_for_prefix(&maps, "sched"),
        (1usize, 1usize, 0usize),
    );
}

// -- is_scx_allocator_type / is_scx_static_type --------------------
//
// Both name-match helpers are only reached inside dump_state's
// sdt_alloc / scx_static pre-passes, which need a live guest
// snapshot. The name-match-vs-wrong-name-vs-non-struct decisions are
// otherwise unpinned; a copy-paste literal regression would ship
// undetected. `build_btf_with_named_struct` emits a 2-type blob:
// id 1 = BTF_KIND_INT u64, id 2 = the named BTF_KIND_STRUCT.

/// Struct-name match arm returns true only for `scx_allocator`; a
/// wrong-name struct is rejected, and passing the INT type id (1)
/// drives the `_ => return false` non-struct arm.
#[test]
fn is_scx_allocator_type_matches_named_struct_and_rejects_wrong_name_and_non_struct() {
    let (blob_ok, struct_id) = build_btf_with_named_struct("scx_allocator");
    let btf_ok = btf_rs::Btf::from_bytes(&blob_ok).expect("synthetic BTF parses");
    assert!(super::is_scx_allocator_type(&btf_ok, struct_id));

    let (blob_bad, other_id) = build_btf_with_named_struct("scx_other");
    let btf_bad = btf_rs::Btf::from_bytes(&blob_bad).expect("synthetic BTF parses");
    assert!(!super::is_scx_allocator_type(&btf_bad, other_id));

    // Type id 1 is the BTF_KIND_INT u64 → non-struct `_` arm → false.
    assert!(!super::is_scx_allocator_type(&btf_ok, 1));
}

/// Mirror of [`is_scx_allocator_type`] but matching `scx_static`.
/// The cross-name negative (a `scx_allocator` struct must be false)
/// guards against a copy-paste bug where `is_scx_static_type`
/// compares against the wrong literal.
#[test]
fn is_scx_static_type_matches_named_struct_and_rejects_others() {
    let (blob, struct_id) = build_btf_with_named_struct("scx_static");
    let btf = btf_rs::Btf::from_bytes(&blob).expect("synthetic BTF parses");
    assert!(super::is_scx_static_type(&btf, struct_id));

    // INT type id 1 → non-struct `_` arm → false.
    assert!(!super::is_scx_static_type(&btf, 1));

    // Wrong-name struct (`scx_allocator`) must be rejected — pins the
    // literal so a copy-paste of the allocator helper is caught.
    let (b2, id2) = build_btf_with_named_struct("scx_allocator");
    let btf2 = btf_rs::Btf::from_bytes(&b2).expect("synthetic BTF parses");
    assert!(!super::is_scx_static_type(&btf2, id2));
}

// -- iter_bss_vars_with_type ---------------------------------------
//
// Both paths are uncovered: the Datasec→Var walk that yields
// (name, offset, type_id) tuples, and the early-return-empty path
// when `resolve_types_by_name` finds no matching section. Loads the
// real probe BTF produced by build.rs at OUT_DIR/probe.o — the same
// host-side fixture pattern as
// `btf_render/tests/datasec_cpumask.rs::load_probe_btf_and_bss_id`.
// The `.bss` var names (ktstr_err_exit_detected, ktstr_pcpu_counters)
// are pinned as `.bss` members by that sibling test, so they are a
// stable ground-truth set.

/// The Datasec walk enumerates every `.bss` variable name; an
/// unknown section name returns an empty Vec (the early-return path).
#[test]
fn iter_bss_vars_with_type_enumerates_probe_bss_vars() {
    let probe = std::path::PathBuf::from(env!("OUT_DIR")).join("probe.o");
    let btf = crate::monitor::btf_offsets::load_btf_from_path(&probe).unwrap_or_else(|e| {
        panic!(
            "load_btf_from_path({}) failed: {e}. build.rs always \
             produces probe.o; a missing or unparseable artifact \
             means the build pipeline is broken.",
            probe.display()
        )
    });
    let vars = super::iter_bss_vars_with_type(&btf, ".bss");
    let names: std::collections::HashSet<&str> = vars.iter().map(|(n, _, _)| n.as_str()).collect();
    assert!(
        names.contains("ktstr_err_exit_detected") && names.contains("ktstr_pcpu_counters"),
        "Datasec walk must enumerate the probe `.bss` vars. Found: {names:?}",
    );
    // Unknown section name → resolve_types_by_name finds nothing →
    // empty iterator path.
    assert!(super::iter_bss_vars_with_type(&btf, ".no_such_section").is_empty());
}

// -- EventCounterSample::from_monitor_sample (overflow clamp) -------
//
// The existing `event_counter_sample_sums_across_cpus` test only
// sums small values, so the documented `saturating_add pins the sum
// at i64::MAX rather than wrapping/panicking` contract is never
// triggered. Two CPUs whose per-CPU s64 counter is near i64::MAX
// would overflow plain `+`.

/// Two CPUs each carrying `select_cpu_fallback = i64::MAX`: the sum
/// clamps at i64::MAX (saturating_add) rather than wrapping to a
/// negative or panicking. Plain `+` would overflow.
#[test]
fn event_counter_sample_saturates_at_i64_max_across_cpus() {
    use super::super::{CpuSnapshot, MonitorSample, ScxEventCounters};
    let big = ScxEventCounters {
        select_cpu_fallback: i64::MAX,
        ..Default::default()
    };
    let sample = MonitorSample {
        bpf_map_fields: Vec::new(),
        elapsed_ms: 7,
        cpus: vec![
            CpuSnapshot {
                event_counters: Some(big.clone()),
                ..Default::default()
            },
            CpuSnapshot {
                event_counters: Some(big),
                ..Default::default()
            },
        ],
        prog_stats: None,
        psi_irq: None,
    };
    let folded = EventCounterSample::from_monitor_sample(&sample)
        .expect("at least one CPU has event_counters");
    // Clamped, not wrapped to a negative.
    assert_eq!(folded.select_cpu_fallback, i64::MAX);
    assert_eq!(folded.elapsed_ms, 7);
}

// -- FailureDumpReport::placeholder --------------------------------
//
// The constructor is uncovered (0 refs to `.placeholder`). Its
// contract — same reason cloned into all five `*_unavailable`
// fields, `is_placeholder = true`, schema stays SCHEMA_SINGLE via
// `..Self::default()`, every data vec empty — is unverified. A field
// dropped from the fan-out would make a degraded capture
// indistinguishable from a real empty dump.

/// Placeholder fans the reason into all five `*_unavailable` fields,
/// flips `is_placeholder`, keeps `schema == SCHEMA_SINGLE`, and
/// leaves the data vecs empty.
#[test]
fn placeholder_report_sets_all_unavailable_reasons_and_flag() {
    let r = FailureDumpReport::placeholder("rendezvous timed out");
    assert_eq!(r.schema, SCHEMA_SINGLE);
    assert!(r.is_placeholder);
    assert_eq!(
        r.prog_runtime_stats_unavailable.as_deref(),
        Some("rendezvous timed out")
    );
    assert_eq!(
        r.per_node_numa_unavailable.as_deref(),
        Some("rendezvous timed out")
    );
    assert_eq!(
        r.task_enrichments_unavailable.as_deref(),
        Some("rendezvous timed out")
    );
    assert_eq!(
        r.scx_walker_unavailable.as_deref(),
        Some("rendezvous timed out")
    );
    assert_eq!(
        r.sdt_alloc_unavailable.as_deref(),
        Some("rendezvous timed out")
    );
    assert!(r.maps.is_empty() && r.prog_runtime_stats.is_empty());
}
