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)"
);
}