Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/korp-collectors

tests/pxweb_tables_and_scb.rs

//! **The SCB half of lane EC O6 — the catalogue that works, and the register that
//! cannot.**
//!
//! Two collectors with opposite fates, and the point of putting them in one file is that
//! the pair is the honest answer to "can korp see Swedish companies?":
//!
//! * [`PxWebTableTree`] walks a PxWeb host's navigation tree. Keyless, live, and it is
//!   what makes `pxweb-jsonstat2` configurable — SCB's SSD root serves **22** subject
//!   areas (measured 2026-09-05) and every table below them is reachable by path.
//! * `scb-foretagsregistret` is the per-organisationsnummer register, and it REFUSES BY
//!   NAME. The keyless PxWeb half tops out at aggregates — SNI × storleksklass × år,
//!   never an orgnr — and that is a permanent property of the API, not a wait.
//!
//! Everything here is offline, through the `CollectorCfg::canned` seam. The live proof
//! is `tests/pxweb_scb_live.rs`.

use std::collections::BTreeMap;

use arrow_array::{Array, Int64Array, StringArray};
use korp_collectors::http::Canned;
use korp_collectors::pxweb::{
    PXWEB_TREE_DEFAULT_DEPTH, PxWebCollector, PxWebTableTree, SCB_CELL_CAP, SCB_FDBR07N_DIMS,
    parse_listing, scb_foretag_sni_storleksklass_cfg,
};
use korp_collectors::requires::{HaveSecrets, NoSecrets, Readiness};
use korp_collectors::rest::{
    RestCollector, SCB_FORETAGSREGISTRET_CONTACT, SCB_FORETAGSREGISTRET_KEY_ENV,
};
use korp_collectors::sink::BatchSink;
use korp_collectors::{Bounds, CollectStats, Collector, CollectorCfg};

const ROOT: &str = "https://pxweb.invalid/ssd";

fn listing(nodes: &[(&str, &str, &str, Option<&str>)]) -> Vec<u8> {
    let v: Vec<serde_json::Value> = nodes
        .iter()
        .map(|(id, ty, text, updated)| match updated {
            Some(u) => serde_json::json!({"id": id, "type": ty, "text": text, "updated": u}),
            None => serde_json::json!({"id": id, "type": ty, "text": text}),
        })
        .collect();
    serde_json::Value::Array(v).to_string().into_bytes()
}

fn walk(cfg: &CollectorCfg) -> (CollectStats, Option<arrow_array::RecordBatch>) {
    let sink = BatchSink::memory();
    let rt = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .expect("runtime");
    let stats = rt
        .block_on(PxWebTableTree.collect(cfg, Bounds::default(), &sink))
        .expect("walk");
    let batches = sink.take_batches();
    let pushed: u64 = batches.iter().map(|b| b.num_rows() as u64).sum();
    assert_eq!(pushed, stats.rows, "the Arrow rows and the stats agree");
    (stats, batches.into_iter().next())
}

fn utf8(b: &arrow_array::RecordBatch, name: &str) -> Vec<Option<String>> {
    let a = b
        .column_by_name(name)
        .unwrap_or_else(|| panic!("no `{name}` column"))
        .as_any()
        .downcast_ref::<StringArray>()
        .unwrap_or_else(|| panic!("`{name}` is not Utf8"));
    (0..a.len())
        .map(|i| (!a.is_null(i)).then(|| a.value(i).to_string()))
        .collect()
}

/// **A listing flattens to nodes whose PATH is where they actually live.**
///
/// The path is the whole identity of a catalogue row: a PxWeb `id` is unique only among
/// its siblings, and `NV0101` under `NV` is not the same node as `NV0101` anywhere else.
#[test]
fn a_listing_flattens_to_nodes_whose_path_is_where_they_live() {
    let root: serde_json::Value = serde_json::from_slice(&listing(&[
        ("AA", "l", "Ämnesövergripande statistik", None),
        ("NV", "l", "Näringsverksamhet", None),
    ]))
    .unwrap();
    let top = parse_listing(&root, "", 1).expect("root listing");
    assert_eq!(
        top.iter().map(|n| n.path.as_str()).collect::<Vec<_>>(),
        vec!["AA", "NV"],
        "a root node's path is its own id — no leading separator"
    );
    assert!(top.iter().all(|n| n.parent_path.is_empty() && n.depth == 1));

    let sub: serde_json::Value = serde_json::from_slice(&listing(&[(
        "FDBR07N",
        "t",
        "Företag efter SNI och storleksklass",
        Some("2025-12-02T08:00:00"),
    )]))
    .unwrap();
    let below = parse_listing(&sub, "NV/NV0101", 3).expect("nested listing");
    assert_eq!(below[0].path, "NV/NV0101/FDBR07N", "joined under its parent");
    assert_eq!(below[0].parent_path, "NV/NV0101");
    assert_eq!(below[0].depth, 3);
}

/// **A table carries `updated`; a folder does not — and that is the SOURCE's answer.**
///
/// PxWeb serves `updated` on `type: t` nodes only. A NULL on a folder row must be
/// readable as "PxWeb has no such stamp for a folder", not as "the walk lost it", which
/// is why the fixture deliberately mixes both kinds.
#[test]
fn a_table_carries_an_updated_stamp_and_a_folder_has_none() {
    let mut m = BTreeMap::new();
    m.insert(
        ROOT.to_string(),
        listing(&[
            ("NV", "l", "Näringsverksamhet", None),
            ("QUICK", "t", "En tabell direkt i roten", Some("2026-01-02T03:04:05")),
        ]),
    );
    m.insert(format!("{ROOT}/NV"), listing(&[]));
    let cfg = CollectorCfg::new(ROOT, "pxweb_tables").with_canned(Canned::new(m));
    let (_, batch) = walk(&cfg);
    let b = batch.expect("one batch");

    let types = utf8(&b, "node_type");
    let updated = utf8(&b, "updated");
    let by_type: Vec<(Option<String>, Option<String>)> =
        types.into_iter().zip(updated).collect();
    let folder = by_type.iter().find(|(t, _)| t.as_deref() == Some("l")).unwrap();
    let table = by_type.iter().find(|(t, _)| t.as_deref() == Some("t")).unwrap();
    assert_eq!(
        folder.1, None,
        "PxWeb serves no `updated` for a folder — NULL is the source's answer"
    );
    assert_eq!(
        table.1.as_deref(),
        Some("2026-01-02T03:04:05"),
        "…and a table's stamp lands verbatim"
    );
}

/// **The walk descends into FOLDERS ONLY — it never asks a table for a listing.**
///
/// A table's url does not serve a listing; it serves the cube's metadata, an OBJECT, and
/// `parse_listing` rejects that as "not an array". So a walk that followed every node
/// would abort on the first table it reached and land nothing at all.
///
/// Asserted through the landed PATHS: the fixture maps a listing for the folder and none
/// for the table, so a walk that descended into the table would land whatever
/// `{ROOT}/FDBR07N` resolved to — and the path set below would not be the three nodes the
/// tree really has.
#[test]
fn the_walk_descends_into_folders_only_and_never_asks_a_table_for_a_listing() {
    let mut m = BTreeMap::new();
    m.insert(
        ROOT.to_string(),
        listing(&[
            ("NV", "l", "Näringsverksamhet", None),
            ("FDBR07N", "t", "En tabell", Some("2025-12-02T08:00:00")),
        ]),
    );
    m.insert(
        format!("{ROOT}/NV"),
        listing(&[("NV0101", "t", "Företagsregistret", Some("2025-12-02T08:00:00"))]),
    );
    // Deliberately ABSENT: `{ROOT}/FDBR07N` and `{ROOT}/NV/NV0101`. Canned errors on an
    // un-mapped url, so asking for either fails the run.
    let cfg = CollectorCfg::new(ROOT, "pxweb_tables").with_canned(Canned::new(m));
    let (stats, batch) = walk(&cfg);

    assert_eq!(stats.rows, 3, "two root nodes + one below NV");
    let b = batch.expect("one batch");
    let mut paths: Vec<String> = utf8(&b, "path").into_iter().flatten().collect();
    paths.sort();
    assert_eq!(paths, vec!["FDBR07N", "NV", "NV/NV0101"]);
    assert!(
        stats.reason.is_none(),
        "a walk that opened every folder it found has nothing to report: {:?}",
        stats.reason
    );
}

/// **A walk cut short by `max_depth` lands what it read and SAYS the catalogue is
/// incomplete.**
///
/// A partial catalogue that reports plain success is worse than a failed run: nothing
/// downstream can tell that the tables it cannot see exist.
#[test]
fn a_depth_bounded_walk_says_the_catalogue_is_incomplete() {
    let mut m = BTreeMap::new();
    m.insert(ROOT.to_string(), listing(&[("NV", "l", "Näringsverksamhet", None)]));
    m.insert(
        format!("{ROOT}/NV"),
        listing(&[("NV0101", "l", "Företagsregistret", None)]),
    );
    m.insert(
        format!("{ROOT}/NV/NV0101"),
        listing(&[("FDBR07N", "t", "En tabell", Some("2025-12-02T08:00:00"))]),
    );
    let cfg = CollectorCfg::new(ROOT, "pxweb_tables")
        .with_canned(Canned::new(m))
        .with_params(serde_json::json!({ "max_depth": 2 }));
    let (stats, _) = walk(&cfg);

    assert_eq!(stats.rows, 2, "the root area and its product — the table is out of reach");
    let reason = stats.reason.as_deref().unwrap_or("");
    assert!(
        reason.contains("max_depth=2"),
        "the reason names the bound it stopped at: {reason}"
    );
    assert!(
        reason.contains("INCOMPLETE"),
        "…and says the catalogue is not the whole catalogue: {reason}"
    );
    assert!(
        PXWEB_TREE_DEFAULT_DEPTH >= 3,
        "the DEFAULT must reach SCB's tables (area → product → table) without config, or \
         every unconfigured run reports an incomplete catalogue"
    );
}

/// **A root node's parent is NULL, never `""`.**
///
/// The graph mapping's `CONTAINED_IN` edge is dropped on a NULL endpoint, which is how
/// "a subject area is contained in nothing" stays true by construction. An empty string
/// is a value: it would point every one of SCB's 22 subject areas at a node named `""`.
#[test]
fn a_root_node_has_a_null_parent_so_its_containment_edge_is_dropped() {
    let mut m = BTreeMap::new();
    m.insert(ROOT.to_string(), listing(&[("NV", "l", "Näringsverksamhet", None)]));
    m.insert(
        format!("{ROOT}/NV"),
        listing(&[("NV0101", "t", "Företagsregistret", Some("2025-12-02T08:00:00"))]),
    );
    let cfg = CollectorCfg::new(ROOT, "pxweb_tables").with_canned(Canned::new(m));
    let (_, batch) = walk(&cfg);
    let b = batch.expect("one batch");

    let paths = utf8(&b, "path");
    let parents = utf8(&b, "parent_path");
    let root_row = paths.iter().position(|p| p.as_deref() == Some("NV")).unwrap();
    let child_row = paths.iter().position(|p| p.as_deref() == Some("NV/NV0101")).unwrap();
    assert_eq!(parents[root_row], None, "a subject area is contained in nothing");
    assert_eq!(parents[child_row].as_deref(), Some("NV"));

    let g = PxWebTableTree
        .graph_mapping()
        .expect("a tree is a graph if anything is");
    assert_eq!(g.rels(), vec!["CONTAINED_IN"]);
    let e = &g.edges[0];
    assert_eq!(
        (e.src.column.as_str(), e.dst.column.as_str()),
        ("path", "parent_path"),
        "the edge is the containment: this node → the node its parent_path names"
    );
    assert_eq!(
        e.dst.id_column, "path",
        "the far endpoint must key on the SAME column the vertex does, or the sink \
         MERGEs a second node for the same folder"
    );
    assert!(
        g.limitation.contains("NULL") && g.limitation.contains("host"),
        "the limitation states both holes: root nodes have no edge, and a path is unique \
         only within one PxWeb host: {}",
        g.limitation
    );
}

/// **The catalogue is keyed on `path`, and the walk really does emit it.**
///
/// The proof that `id` could not be the key is in the same batch: two different tables
/// under two different products can carry the same id, and keyed on `id` an Upsert would
/// collapse them into one row.
#[test]
fn the_catalogue_is_keyed_on_path_because_an_id_repeats_across_folders() {
    let key = PxWebTableTree.business_key();
    assert_eq!(key.columns, vec!["path".to_string()]);
    let schema = PxWebTableTree
        .schema(&CollectorCfg::new(ROOT, "pxweb_tables"))
        .expect("the tree schema needs no config");
    assert!(
        key.missing_from(&schema).is_empty(),
        "the key names a column the collector emits"
    );

    let mut m = BTreeMap::new();
    m.insert(
        ROOT.to_string(),
        listing(&[("AM", "l", "Arbetsmarknad", None), ("NV", "l", "Näringsverksamhet", None)]),
    );
    // The SAME id under two different parents — which really happens on a PxWeb host.
    for parent in ["AM", "NV"] {
        m.insert(
            format!("{ROOT}/{parent}"),
            listing(&[("TABELL1", "t", "En tabell", Some("2026-01-01T00:00:00"))]),
        );
    }
    let cfg = CollectorCfg::new(ROOT, "pxweb_tables").with_canned(Canned::new(m));
    let (stats, batch) = walk(&cfg);
    assert_eq!(stats.rows, 4, "two areas + one table under each");

    let b = batch.expect("one batch");
    let ids: Vec<String> = utf8(&b, "id").into_iter().flatten().collect();
    let paths: Vec<String> = utf8(&b, "path").into_iter().flatten().collect();
    assert_eq!(
        ids.iter().filter(|i| *i == "TABELL1").count(),
        2,
        "the id repeats — this is the case that rules `id` out as the key"
    );
    let distinct: std::collections::BTreeSet<&String> = paths.iter().collect();
    assert_eq!(distinct.len(), paths.len(), "…and every path is still distinct");

    let depth = b
        .column_by_name("depth")
        .expect("depth column")
        .as_any()
        .downcast_ref::<Int64Array>()
        .expect("depth is Int64");
    assert!(
        (0..depth.len()).any(|i| depth.value(i) == 2),
        "the second level is really reached, and says which level it is"
    );
}

/// **The tree re-reads everything, and the cadence follows.**
///
/// There is no honest alternative: a PxWeb listing serves no validator, and a 304 on the
/// ROOT would say nothing about the folders below it. `AlwaysFull` is the declaration
/// that makes the daemon wait 24 hours instead of five minutes — and for a walk that
/// costs one request per folder, the interval IS the cost.
#[test]
fn the_catalogue_rereads_everything_so_it_reads_once_a_day() {
    let f = PxWebTableTree.freshness();
    assert!(
        f.rereads_everything(),
        "a tree walk is not a delta and must not claim to be: {}",
        f.label()
    );
    assert!(
        !f.saves_bytes(),
        "and a pane must not show a transfer saving that no run makes"
    );
    assert_eq!(
        f.default_refresh_secs(),
        86_400,
        "24 h — every read transfers the whole tree, so the interval is the whole cost"
    );
}

/// **`scb-foretagsregistret` REFUSES, and the refusal names the variable and the mail
/// address.**
///
/// This is the lane's blocker made legible. With nothing configured, ▶ Collect and the
/// daemon must both refuse — and the sentence an operator reads has to contain the thing
/// they would have to go and get. A collector that reported "not configured" would be
/// telling them nothing they did not already know.
#[test]
fn scb_foretagsregistret_refuses_by_name_and_says_what_to_go_and_get() {
    let c = RestCollector::scb_foretagsregistret();
    let req = c.requirements();
    let state = req.readiness(&NoSecrets, &serde_json::json!({}), true, false);

    let refusal = match &state {
        Readiness::NeedsConfig { .. } => state.refusal().expect("a blocked collector refuses"),
        other => panic!("expected NeedsConfig on an unconfigured box, got {other:?}"),
    };
    println!("refusal: {refusal}");

    assert!(
        refusal.contains(SCB_FORETAGSREGISTRET_KEY_ENV),
        "the refusal names the ENV VAR, so an operator knows what to set: {refusal}"
    );
    assert!(
        refusal.contains(SCB_FORETAGSREGISTRET_CONTACT),
        "…and the mail address it comes from, because it is not self-service: {refusal}"
    );
    assert!(
        refusal.contains("placeholder") || refusal.contains("feed URL"),
        "…and that the ENDPOINT is missing too — SCB issues the url with the credential \
         and publishes it nowhere, so a key alone would not make this runnable: {refusal}"
    );
    assert!(
        refusal.contains("aggregat") || refusal.contains("orgnr"),
        "…and what is lost meanwhile: the keyless SCB half is aggregates, never an \
         orgnr: {refusal}"
    );
}

/// **The key alone is not enough, and the collector says which half is still missing.**
///
/// The green arm of the guard above. `NoSecrets` can only ever prove that something is
/// refused; it cannot prove that the refusal tracks the actual state. With the key
/// present the collector is STILL blocked — on the endpoint — and a declaration that
/// flipped to `Runnable` here would send a run at a placeholder host.
#[test]
fn the_key_alone_does_not_unblock_it_because_scb_publishes_no_endpoint() {
    let c = RestCollector::scb_foretagsregistret();
    let req = c.requirements();
    let with_key = req.readiness(
        &HaveSecrets::of(&[SCB_FORETAGSREGISTRET_KEY_ENV]),
        &serde_json::json!({}),
        true,
        false,
    );
    let refusal = with_key
        .refusal()
        .expect("still blocked — the endpoint is a placeholder");
    println!("with the key: {refusal}");
    assert!(
        !refusal.contains(SCB_FORETAGSREGISTRET_KEY_ENV),
        "the satisfied requirement drops out of the refusal: {refusal}"
    );
    assert!(
        refusal.contains("placeholder") || refusal.contains("feed URL"),
        "and what remains is the endpoint: {refusal}"
    );
}

/// **The blocked collector emits NO columns, and its key says append-only.**
///
/// SCB publishes no response layout either — the variable list is a per-subscriber
/// postbeskrivning PDF issued with the credential. A guessed field map would land a table
/// of nulls and pass every structural check in this crate, so there is none; and a
/// business key on `orgnr` would name a column nothing emits, which is exactly the defect
/// lane EC O3 found 29 instances of.
#[test]
fn the_blocked_collector_declares_no_columns_it_has_never_seen() {
    let c = RestCollector::scb_foretagsregistret();
    let cfg = CollectorCfg::new("https://example.invalid/", "scb_foretagsregistret");
    let schema = c.schema(&cfg).expect("a schema, even an empty one");
    assert!(
        schema.fields().is_empty(),
        "nobody has seen this API's document — a field map written from a guess would \
         land nulls and look like success: {:?}",
        schema.fields()
    );
    let key = c.business_key_for(&cfg);
    assert!(
        key.columns.is_empty(),
        "append-only is the only honest key over an unknown layout, got {:?}",
        key.columns
    );
    assert!(
        c.graph_mapping().is_none(),
        "and no graph: an entity model over a document nobody has read is fiction"
    );
    assert!(
        c.meta().join_keys.is_empty(),
        "not even `orgnr` — declaring the join key korp WANTS over a collector that \
         emits nothing is the O3 defect in its purest form"
    );
}

/// **The SNI × storleksklass × år cfg keys on every dimension of the cube it will
/// actually fetch.**
///
/// The cube collector's key is cfg-driven — a JSON-stat2 observation is identified by its
/// full coordinate — so the cfg must carry `dims` and not only the selection. A cfg with
/// the selection alone falls back to `region · year · ContentsCode`, three columns this
/// cube does not have.
#[test]
fn the_scb_aggregate_cfg_keys_on_the_dimensions_it_will_really_emit() {
    let cfg = scb_foretag_sni_storleksklass_cfg();
    let key = PxWebCollector.business_key_for(&cfg);
    assert_eq!(
        key.columns,
        SCB_FDBR07N_DIMS.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
        "SNI2007 · Storleksklass · ContentsCode · Tid — the whole coordinate"
    );
    let schema = PxWebCollector.schema(&cfg).expect("cube schema from the cfg");
    assert!(
        key.missing_from(&schema).is_empty(),
        "and every one of them is a column the collector emits"
    );
    assert!(
        !key.columns.iter().any(|c| c == "region" || c == "year"),
        "the fallback key would have named `region` and `year`, which this cube has \
         neither of: {:?}",
        key.columns
    );
}

/// **The POST body calls the array `query`, and SCB 400s if it does not.**
///
/// The bug this file's live sibling found. `collect` sent `{"selection": …}` from the
/// first version of the PxWeb collector; PxWeb requires `{"query": …}`, and a body keyed
/// `selection` answers HTTP 400 Bad Request with a bare text body — no JSON, no field
/// name to go on. Measured 2026-09-05 against `NV/NV0101/FDBR07N`: the identical
/// selection is 400 under `selection` and 200 under `query`.
///
/// It survived because the only test that existed asserted `parse_jsonstat2` over a
/// canned cube — the SHAPER, never the REQUEST. So the request document is now a pure
/// function and this asserts it.
#[test]
fn the_post_body_names_the_array_query_which_is_what_pxweb_requires() {
    use korp_collectors::pxweb::pxweb_request_document;
    let sel = korp_collectors::pxweb::scb_foretag_sni_storleksklass_selection();
    let doc = pxweb_request_document(&sel);

    assert!(
        doc.get("query").is_some(),
        "PxWeb's request document names the array `query`: {doc}"
    );
    assert!(
        doc.get("selection").is_none(),
        "`selection` is the CFG key, never the wire key — a body keyed `selection` is \
         HTTP 400 at SCB, with a bare text body that names nothing: {doc}"
    );
    assert_eq!(
        doc["query"], sel,
        "the selection travels through unchanged — this function renames the field and \
         does nothing else"
    );
    assert_eq!(
        doc["response"]["format"], "json-stat2",
        "and asks for the format `parse_jsonstat2` can actually read"
    );
}

/// **A folder that declares itself a folder and then refuses does NOT cost the
/// catalogue.**
///
/// Measured live 2026-09-05: walking SCB's `NV` branch, **8 of 68** folders (11.8 %)
/// answered HTTP 400 Bad Request when asked for their listing — `NV0802/NV0802Z` ("Äldre
/// tabeller som inte uppdateras") and seven siblings — while the rest answered 200. A
/// `?` on that fetch loses all 170 tables to the first stale folder, and the operator
/// sees an error naming one url with no hint that a catalogue was in flight.
///
/// So a refusing branch is skipped and NAMED. The root is the exception: a walk whose
/// own starting url does not answer has no catalogue, and a cheerful zero there is the
/// silent green this crate's RAGNARÖK law forbids.
#[test]
fn a_folder_that_refuses_is_skipped_and_named_not_fatal() {
    let mut m = BTreeMap::new();
    m.insert(
        ROOT.to_string(),
        listing(&[
            ("GOOD", "l", "En mapp som svarar", None),
            ("STALE", "l", "Äldre tabeller som inte uppdateras", None),
        ]),
    );
    m.insert(
        format!("{ROOT}/GOOD"),
        listing(&[("T1", "t", "En tabell", Some("2026-01-01T00:00:00"))]),
    );
    // The body SCB really serves for those eight folders: the bare text `Bad Request`,
    // which is not JSON and certainly not a listing.
    //
    // It is mapped EXPLICITLY rather than left absent, and that is worth knowing:
    // `Canned` falls back to the longest key that PREFIXES the url, so an unmapped
    // `{ROOT}/STALE` would have been served the ROOT listing — the walk would have found
    // GOOD and STALE below STALE and recursed until the depth bound, landing 17 rows.
    // (That is how this test first failed, and it is a real hazard for any PxWeb host
    // that answers a child path with its parent's document.)
    m.insert(format!("{ROOT}/STALE"), b"Bad Request".to_vec());
    let cfg = CollectorCfg::new(ROOT, "pxweb_tables").with_canned(Canned::new(m));
    let (stats, batch) = walk(&cfg);

    assert_eq!(
        stats.rows, 3,
        "the two root nodes and GOOD's table — the stale branch costs only itself"
    );
    let b = batch.expect("one batch");
    let mut paths: Vec<String> = utf8(&b, "path").into_iter().flatten().collect();
    paths.sort();
    assert_eq!(paths, vec!["GOOD", "GOOD/T1", "STALE"]);

    let reason = stats.reason.as_deref().unwrap_or("");
    assert!(
        reason.contains("STALE"),
        "the reason NAMES the folder that refused, so its missing tables are findable: \
         {reason}"
    );
    assert!(
        reason.contains("REFUSED") || reason.contains("missing"),
        "…and says its tables are missing from this catalogue: {reason}"
    );
}

/// **A walk whose ROOT does not answer is an error, not an empty catalogue.**
///
/// The other arm of the rule above, and the one that keeps the skip from becoming a
/// blanket "ignore failures": if the starting url is unreachable there is no catalogue
/// at all, and landing zero rows with a tidy reason would be a collector reporting
/// success over a source it never reached.
#[test]
fn a_walk_whose_root_does_not_answer_fails_instead_of_landing_nothing() {
    let cfg = CollectorCfg::new(ROOT, "pxweb_tables").with_canned(Canned::new(BTreeMap::new()));
    let sink = BatchSink::memory();
    let rt = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .expect("runtime");
    let err = rt
        .block_on(PxWebTableTree.collect(&cfg, Bounds::default(), &sink))
        .expect_err("an unreachable root is a failed run, never an empty catalogue");
    println!("root failure: {err:#}");
    assert!(
        format!("{err:#}").contains("walk root"),
        "and the error says it was the ROOT that did not answer, not some branch: {err:#}"
    );
    assert!(sink.take_batches().is_empty(), "nothing was landed");
}

/// **The declared selection stays inside SCB's per-call cell cap — by construction.**
///
/// The full cube is 1 823 SNI codes × 9 size classes × 1 measure × 18 years = 295 326
/// cells, and asking for all of it answers **HTTP 403 with an IIS HTML error page**
/// (measured 2026-09-05), not a JSON error a parser could explain. The selection pins
/// SNI to the 22 top-level divisions and the measure to one code, which is 3 564 cells.
#[test]
fn the_declared_selection_pins_the_two_dimensions_that_would_blow_the_cell_cap() {
    let sel = korp_collectors::pxweb::scb_foretag_sni_storleksklass_selection();
    let by = |code: &str| -> serde_json::Value {
        sel.as_array()
            .unwrap()
            .iter()
            .find(|q| q["code"] == code)
            .unwrap_or_else(|| panic!("selection names `{code}`"))
            .clone()
    };
    let sni = by("SNI2007");
    assert_eq!(sni["selection"]["filter"], "item", "SNI is ITEMISED, not `all`");
    let sni_n = sni["selection"]["values"].as_array().unwrap().len();
    assert_eq!(sni_n, 22, "`A-U` plus the 21 lettered sections");

    let contents = by("ContentsCode");
    assert_eq!(
        contents["selection"]["values"].as_array().unwrap().len(),
        1,
        "the measure is PINNED to one code — an `all` that widened when SCB adds a \
         second measure is how a cell budget stops being a budget"
    );

    // 22 SNI × 9 size classes × 1 measure × 18 years, from the live metadata.
    let cells = sni_n * 9 * 1 * 18;
    assert_eq!(cells, 3_564, "the selection this lane actually fetched live");
    assert!(
        cells < SCB_CELL_CAP,
        "…and it is inside SCB's 150 000-cell per-call cap, where the full 295 326-cell \
         cube is not (that one answers HTTP 403 with an HTML page)"
    );
}