2026-08-28 19:58:02 +01:00
|
|
|
"""The destinations serialiser's contract.
|
2026-08-28 16:10:29 +01:00
|
|
|
|
2026-08-28 19:58:02 +01:00
|
|
|
Not rendering a figure is not the same as not publishing it. This endpoint is
|
|
|
|
|
public and unauthenticated, so whatever the payload carries is published,
|
|
|
|
|
whatever the UI draws. The categories sum to the cohort and the pupil groups
|
|
|
|
|
sum to each other, so a lone suppressed cell is solvable by subtraction — the
|
|
|
|
|
serialiser adds secondary suppression to prevent it.
|
|
|
|
|
|
|
|
|
|
See docs/superpowers/specs/2026-08-28-destination-measures-design.md.
|
2026-08-28 16:10:29 +01:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from backend.data_loader import _destinations_block, _format_cohort_year
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _row(group, measure, pupils, status, cohort=180, percentage=None, year=202223):
|
|
|
|
|
return {
|
|
|
|
|
"pupil_group": group,
|
|
|
|
|
"destination_measure": measure,
|
|
|
|
|
"pupils": pupils,
|
|
|
|
|
"percentage": percentage,
|
|
|
|
|
"status": status,
|
|
|
|
|
"cohort_pupils": cohort,
|
|
|
|
|
"year": year,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_suppressed_category_serialises_as_suppressed_with_null_pupils():
|
|
|
|
|
rows = [
|
|
|
|
|
_row("all", "school_sixth_form", 75, "published", percentage=41.7),
|
|
|
|
|
_row("all", "sixth_form_college", None, "suppressed"),
|
|
|
|
|
]
|
|
|
|
|
block = _destinations_block(rows)
|
|
|
|
|
cats = {c["category"]: c for c in block["groups"]["all"]["categories"]}
|
|
|
|
|
assert cats["sixth_form_college"]["status"] == "suppressed"
|
|
|
|
|
assert cats["sixth_form_college"]["pupils"] is None
|
|
|
|
|
assert cats["sixth_form_college"]["percentage"] is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_published_category_keeps_its_figures():
|
|
|
|
|
block = _destinations_block([
|
|
|
|
|
_row("all", "school_sixth_form", 75, "published", percentage=41.7),
|
|
|
|
|
])
|
|
|
|
|
cat = block["groups"]["all"]["categories"][0]
|
|
|
|
|
assert cat["pupils"] == 75
|
|
|
|
|
assert cat["percentage"] == 41.7
|
|
|
|
|
assert cat["status"] == "published"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_only_the_latest_year_is_served():
|
|
|
|
|
rows = [
|
|
|
|
|
_row("all", "school_sixth_form", 60, "published", year=202122),
|
|
|
|
|
_row("all", "school_sixth_form", 75, "published", year=202223),
|
|
|
|
|
]
|
|
|
|
|
block = _destinations_block(rows)
|
|
|
|
|
assert block["cohort_year"] == "2022/23"
|
|
|
|
|
assert len(block["groups"]["all"]["categories"]) == 1
|
|
|
|
|
assert block["groups"]["all"]["categories"][0]["pupils"] == 75
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_all_three_pupil_groups_are_carried():
|
|
|
|
|
rows = [
|
|
|
|
|
_row("all", "school_sixth_form", 75, "published"),
|
|
|
|
|
_row("disadvantaged", "school_sixth_form", 17, "published", cohort=62),
|
|
|
|
|
_row("other", "school_sixth_form", 58, "published", cohort=118),
|
|
|
|
|
]
|
|
|
|
|
block = _destinations_block(rows)
|
|
|
|
|
assert set(block["groups"]) == {"all", "disadvantaged", "other"}
|
|
|
|
|
assert block["groups"]["disadvantaged"]["cohort"] == 62
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cohort_year_is_reported_so_the_page_can_date_itself():
|
|
|
|
|
block = _destinations_block([_row("all", "school_sixth_form", 75, "published")])
|
|
|
|
|
assert block["cohort_year"] == "2022/23"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_format_cohort_year_handles_the_six_digit_form():
|
|
|
|
|
assert _format_cohort_year(202223) == "2022/23"
|
|
|
|
|
assert _format_cohort_year(None) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_empty_rows_yield_none_not_an_empty_shell():
|
|
|
|
|
assert _destinations_block([]) is None
|
2026-08-28 19:58:02 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Disclosure control ──────────────────────────────────────────────────────
|
|
|
|
|
#
|
|
|
|
|
# The rendering guards in lib/destinations.ts stop a withheld figure being
|
|
|
|
|
# DRAWN. They do nothing about it being COMPUTED: this endpoint is public and
|
|
|
|
|
# unauthenticated, so whatever the payload carries is published. These tests
|
|
|
|
|
# are the ones that matter.
|
|
|
|
|
|
|
|
|
|
def _solve_residual(group):
|
|
|
|
|
"""What any caller can work out: cohort minus everything published."""
|
|
|
|
|
published = [c["pupils"] for c in group["categories"] if c["pupils"] is not None]
|
|
|
|
|
hidden = [c for c in group["categories"] if c["status"] == "suppressed"]
|
|
|
|
|
return group["cohort"] - sum(published), len(hidden)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_lone_suppressed_category_cannot_be_solved_for():
|
|
|
|
|
"""Whitley Bay High School's real 2022/23 disadvantaged group: further
|
|
|
|
|
education withheld, everything else published, cohort 41. Before secondary
|
|
|
|
|
suppression the payload gave the answer away as 41 - 23 = 18."""
|
|
|
|
|
rows = [
|
|
|
|
|
_row("disadvantaged", "school_sixth_form", 15, "published", cohort=41),
|
|
|
|
|
_row("disadvantaged", "sixth_form_college", 0, "published", cohort=41),
|
|
|
|
|
_row("disadvantaged", "further_education", None, "suppressed", cohort=41),
|
|
|
|
|
_row("disadvantaged", "apprenticeship", 1, "published", cohort=41),
|
|
|
|
|
_row("disadvantaged", "employment", 2, "published", cohort=41),
|
|
|
|
|
_row("disadvantaged", "not_sustained", 3, "published", cohort=41),
|
|
|
|
|
_row("disadvantaged", "not_captured", 2, "published", cohort=41),
|
|
|
|
|
]
|
|
|
|
|
group = _destinations_block(rows)["groups"]["disadvantaged"]
|
|
|
|
|
residual, hidden = _solve_residual(group)
|
|
|
|
|
assert hidden >= 2, "a lone suppressed cell must gain a companion"
|
|
|
|
|
assert residual != 18, "the withheld figure is recoverable from the payload"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_every_group_hides_none_or_at_least_two_categories():
|
|
|
|
|
rows = [
|
|
|
|
|
_row("all", "school_sixth_form", 75, "published"),
|
|
|
|
|
_row("all", "sixth_form_college", None, "suppressed"),
|
|
|
|
|
_row("all", "further_education", 61, "published"),
|
|
|
|
|
_row("all", "apprenticeship", 8, "published"),
|
|
|
|
|
_row("all", "employment", 6, "published"),
|
|
|
|
|
_row("all", "not_sustained", 5, "published"),
|
|
|
|
|
_row("all", "not_captured", 4, "published"),
|
|
|
|
|
]
|
|
|
|
|
group = _destinations_block(rows)["groups"]["all"]
|
|
|
|
|
hidden = [c for c in group["categories"] if c["status"] == "suppressed"]
|
|
|
|
|
assert len(hidden) >= 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_category_hidden_in_one_group_is_hidden_in_a_second():
|
|
|
|
|
"""disadvantaged + other = all for every category, so a category withheld
|
|
|
|
|
in exactly one of the three is recoverable from the other two."""
|
|
|
|
|
rows = []
|
|
|
|
|
for measure, a, d, o in [
|
|
|
|
|
("school_sixth_form", 75, None, 58),
|
|
|
|
|
("further_education", 61, 27, 34),
|
|
|
|
|
("apprenticeship", 8, 4, 4),
|
|
|
|
|
("employment", 6, 1, 5),
|
|
|
|
|
("not_sustained", 5, 3, 2),
|
|
|
|
|
("not_captured", 4, 2, 2),
|
|
|
|
|
]:
|
|
|
|
|
rows.append(_row("all", measure, a, "published", cohort=159))
|
|
|
|
|
rows.append(_row("disadvantaged", measure, d,
|
|
|
|
|
"published" if d is not None else "suppressed", cohort=37))
|
|
|
|
|
rows.append(_row("other", measure, o, "published", cohort=122))
|
|
|
|
|
|
|
|
|
|
groups = _destinations_block(rows)["groups"]
|
|
|
|
|
for measure in ["school_sixth_form"]:
|
|
|
|
|
hidden = sum(
|
|
|
|
|
1 for key in ("all", "disadvantaged", "other")
|
|
|
|
|
for c in groups[key]["categories"]
|
|
|
|
|
if c["category"] == measure and c["status"] == "suppressed"
|
|
|
|
|
)
|
|
|
|
|
assert hidden >= 2, f"{measure} is solvable across the pupil groups"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_suppressed_cell_never_keeps_its_percentage():
|
|
|
|
|
"""percentage / pupils would hand back the cohort, and with it the residual."""
|
|
|
|
|
rows = [
|
|
|
|
|
_row("all", "school_sixth_form", 75, "published", percentage=41.7),
|
|
|
|
|
_row("all", "sixth_form_college", None, "suppressed", percentage=11.7),
|
|
|
|
|
_row("all", "further_education", 61, "published", percentage=33.9),
|
|
|
|
|
]
|
|
|
|
|
group = _destinations_block(rows)["groups"]["all"]
|
|
|
|
|
for cell in group["categories"]:
|
|
|
|
|
if cell["status"] != "published":
|
|
|
|
|
assert cell["pupils"] is None
|
|
|
|
|
assert cell["percentage"] is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_aggregates_are_not_served():
|
|
|
|
|
"""An aggregate spanning exactly one suppressed component names it, and
|
|
|
|
|
nothing renders them today."""
|
|
|
|
|
rows = [
|
|
|
|
|
_row("all", "school_sixth_form", 75, "published"),
|
|
|
|
|
_row("all", "agg_sustained_all", 171, "published"),
|
|
|
|
|
]
|
|
|
|
|
group = _destinations_block(rows)["groups"]["all"]
|
|
|
|
|
assert [c["category"] for c in group["categories"]] == ["school_sixth_form"]
|
|
|
|
|
assert "aggregates" not in group
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_fully_published_group_is_left_alone():
|
|
|
|
|
"""Secondary suppression must not cost anything where nothing is withheld —
|
|
|
|
|
this is the all-pupils view on every mainstream secondary."""
|
|
|
|
|
rows = [
|
|
|
|
|
_row("all", m, p, "published")
|
|
|
|
|
for m, p in [("school_sixth_form", 75), ("sixth_form_college", 21),
|
|
|
|
|
("further_education", 61), ("apprenticeship", 8),
|
|
|
|
|
("employment", 6), ("not_sustained", 5), ("not_captured", 4)]
|
|
|
|
|
]
|
|
|
|
|
group = _destinations_block(rows)["groups"]["all"]
|
|
|
|
|
assert all(c["status"] == "published" for c in group["categories"])
|
|
|
|
|
assert len(group["categories"]) == 7
|