jobbsok/tests/test_kandidatprofil_schema.py

331 lines
13 KiB
Python

"""The section 5.1 contract check for `profil/kandidat.md` (plan Step 8).
Two halves, and the second is the reason this file is longer than the plan's
test list.
The first half is the plan's own nine behaviours: a valid profile matches its
golden report, a missing required key is an error that names the key path, an
unknown key is a warning and not an error, weights are read from the profile
or fall back to the shipped defaults, a wrong section order is reported,
malformed frontmatter raises the named parse error rather than a traceback,
and Norwegian characters survive the round trip.
The second half exists because Step 8's own instruction is that **where the
operator's real file and section 5.1 disagree, the file wins and the schema is
widened**. Reading the real file on 2026-09-04 produced seven such
divergences. Six of them land here (the seventh, travel time as a soft filter,
is scoring's and lands in tests/test_kandidatvurdering_scoring.py), and each
gets a test, because a widening without a test is a claim rather than a
contract.
The divergence profiles are built in `tmp_path`, never added to
`tests/fixtures/`. Two reasons, both hard: the corpus size is asserted by
tests/test_fixture_hygiene.py, which this step must not touch, and the real
file carries a salary floor, absolute nos and known weaknesses onto a PUBLIC
remote. So what is reused here is the real file's *structure* -- a comma-
separated employment form, a top-level `arbeidssteder` list, slash
alternatives, free-text seniority, an explicitly unmeasured outcome -- with
invented content in every field. No test in this file reads the operator's
workspace.
Style note: this file follows tests/test_frontmatter.py.
"""
import json
import os
import pytest
import kandidat_schema
from jobbsok_lib import frontmatter
GOLDEN = os.path.join(os.path.dirname(os.path.abspath(__file__)), "golden")
def profile(fixtures_dir, name):
with open(os.path.join(fixtures_dir, "profiles", name), "r", encoding="utf-8") as handle:
return handle.read()
def keys_in(entries):
return [entry["nokkel"] for entry in entries]
#: An anonymous profile carrying the *shape* of every divergence the real file
#: showed, and none of its content. Written as a template so a single test can
#: vary one field and leave the rest of the shape intact.
DIVERGENT = """---
oppdatert: 2026-09-04
geografi:
base: Vestbygda i Sørdal
maks_reisetid_min: 45
hjemmekontor_min_dager: 2
vurderer_flytting: %(flytting)s
arbeidssteder:
- Sørdal
- Vestbygda
- Nordvik
lonn:
gulv_nok: 930000
onsket_nok: 1010000
kommentar: Gulvet er hardt. Ønsket nivå teller bare i vektingen.
ansettelsesform:
aksepterer: %(aksepterer)s
avviser: åremål/engasjement, vikariat
absolutte_nei:
- nattarbeid / turnusvakt
- reise over halve tiden
senioritet:
min: %(min)s
maks: %(maks)s
---
# Kandidatprofil
## Kjerne
Bygger fagsystemer som faktisk tas i bruk.
## Kompetanse
### Dybde
- **Plattformarbeid.** Belegg: fire fagsystemer i produksjon.
### Bredde
- Teknologisk breddeforståelse og intuisjon — velger riktig verktøy uten å ha
brukt det før.
### Under oppbygging
- Evals og systematisk måling
## Retning
### Mot
- Roller som er strategiske og hands-on i samme stilling.
### Bort fra
- Administrasjon som i praksis bare er friksjon.
## Signaturprosjekter
### Kartinnsyn for fagavdelingene
- **Situasjon:** ingen felles innsynsflate.
- **Bidrag:** full konseptutvikling.
- **Målbart utfall:** *ikke målt.* Kvalitativt: fagavdelingene fikk for første
gang samme kart. Ingen tall er hentet inn, og det er en ekte tilstand — ikke
et estimat.
- **Dekker krav om:** kartdata, konseptutvikling.
## Kjente svakheter
| Svakhet | Kompensasjon | Tiltak |
| --- | --- | --- |
| Konsept, ikke drift. | Rask fra idé til akseptert konsept. | Eie én løsning gjennom et driftsår. |
## Formuleringer som virker
- «Jeg bygger verktøyet, ikke bare med det.»
## Profilgap
- **Driftserfaring.** Ingen av prosjektene er i produksjon.
"""
DIVERGENT_DEFAULTS = {
"flytting": "nei",
"aksepterer": "fast, konsulent via byrå",
"min": "seniorkonsulent",
"maks": "sjefkonsulent/avdelingsdirektør",
}
def divergent(**overrides):
fields = dict(DIVERGENT_DEFAULTS)
fields.update(overrides)
return DIVERGENT % fields
# ---------------------------------------------------------------------------
# The plan's nine behaviours
# ---------------------------------------------------------------------------
def test_valid_profile_matches_its_golden_report(fixtures_dir, golden):
report = kandidat_schema.validate(profile(fixtures_dir, "01-gyldig.md"))
assert report["gyldig"] is True
golden(os.path.join(GOLDEN, "kandidat-gyldig.report.json"),
kandidat_schema.report_json(report))
def test_missing_salary_floor_is_an_error_that_names_the_key(fixtures_dir):
report = kandidat_schema.validate(profile(fixtures_dir, "02-mangler-lonnsgulv.md"))
assert report["gyldig"] is False
assert "lonn.gulv_nok" in keys_in(report["feil"])
# The other salary keys are present, so exactly one salary error, not a
# cascade that buries the one key the operator has to add.
assert [k for k in keys_in(report["feil"]) if k.startswith("lonn.")] == ["lonn.gulv_nok"]
def test_missing_geography_is_an_error_that_names_the_key_path(fixtures_dir):
report = kandidat_schema.validate(profile(fixtures_dir, "03-mangler-geografi.md"))
assert report["gyldig"] is False
missing = keys_in(report["feil"])
# The whole block is gone, so every leaf under it is named -- naming only
# `geografi` would leave the operator guessing which four keys to write.
assert "geografi" in missing
for leaf in ("base", "maks_reisetid_min", "hjemmekontor_min_dager", "vurderer_flytting"):
assert "geografi.%s" % leaf in missing
def test_unknown_key_is_a_warning_and_not_an_error(fixtures_dir):
report = kandidat_schema.validate(profile(fixtures_dir, "04-ukjent-nokkel.md"))
assert report["gyldig"] is True
assert report["feil"] == []
assert "favorittfarge" in keys_in(report["advarsler"])
def test_explicit_weights_are_parsed_from_the_profile(fixtures_dir):
report = kandidat_schema.validate(profile(fixtures_dir, "05-eksplisitte-vekter.md"))
assert report["gyldig"] is True
assert report["vekter_kilde"] == "profil"
assert report["vekter"] == {
"fagomrade": 4,
"oppgavetype": 3,
"teknologi": 2,
"selskapstype": 1,
}
# And they really are the profile's, not the shipped defaults wearing the
# profile's label.
assert report["vekter"] != kandidat_schema.STANDARDVEKTER
def test_profile_without_weights_reports_the_default_source(fixtures_dir):
report = kandidat_schema.validate(profile(fixtures_dir, "06-uten-vekter.md"))
assert report["gyldig"] is True
assert report["vekter_kilde"] == "default"
assert report["vekter"] == kandidat_schema.STANDARDVEKTER
def test_wrong_section_order_is_reported(fixtures_dir):
report = kandidat_schema.validate(profile(fixtures_dir, "07-feil-seksjonsrekkefolge.md"))
assert report["gyldig"] is False
assert "seksjoner" in keys_in(report["feil"])
# All seven are present -- the defect is the order alone, and the message
# has to say which section arrived out of turn.
assert len(report["seksjoner"]) == 7
melding = [f["melding"] for f in report["feil"] if f["nokkel"] == "seksjoner"][0]
assert "Profilgap" in melding and "Kjerne" in melding
def test_invalid_frontmatter_raises_the_named_parse_error(fixtures_dir):
text = profile(fixtures_dir, "08-ugyldig-yaml.md")
with pytest.raises(frontmatter.FrontmatterError) as excinfo:
kandidat_schema.validate(text)
# The tab is on line 7 of that fixture; the point is that the error names
# a line at all rather than surfacing as a traceback from deeper down.
assert excinfo.value.line == 7
assert "tab" in str(excinfo.value)
def test_norwegian_characters_round_trip_through_the_report():
text = divergent()
report = kandidat_schema.validate(text)
rendered = kandidat_schema.report_json(report)
assert "Sørdal" in rendered and "byrå" in rendered and "avdelingsdirektør" in rendered
# Not escaped to ø on the way out, and not normalised on the way in:
# the report quotes the operator's own bytes back.
assert "\\u" not in rendered
meta, _body = frontmatter.parse(text)
assert meta["geografi"]["base"] == "Vestbygda i Sørdal"
# ---------------------------------------------------------------------------
# The divergences the real file forced (see the module docstring)
# ---------------------------------------------------------------------------
def test_divergence_1_comma_splits_employment_forms_and_slash_splits_alternatives():
report = kandidat_schema.validate(divergent())
assert report["gyldig"] is True
# The grammar from Step 4 allows only a scalar under a mapping, so the
# list lives inside the scalar and the comma is the contract.
assert report["ansettelsesform"]["aksepterer"] == [["fast"], ["konsulent via byrå"]]
assert report["ansettelsesform"]["avviser"] == [["åremål", "engasjement"], ["vikariat"]]
# Same two separators, same meaning, on the absolute-no list.
assert report["absolutte_nei"] == [
["nattarbeid", "turnusvakt"],
["reise over halve tiden"],
]
def test_divergence_2_arbeidssteder_warns_but_is_named_as_honoured():
report = kandidat_schema.validate(divergent())
assert report["gyldig"] is True
assert "arbeidssteder" in keys_in(report["advarsler"])
melding = [w["melding"] for w in report["advarsler"] if w["nokkel"] == "arbeidssteder"][0]
# A key outside 5.1 that scoring nevertheless reads must not look like the
# typo it is otherwise indistinguishable from.
assert "vurdering" in melding
assert report["arbeidssteder"] == ["Sørdal", "Vestbygda", "Nordvik"]
def test_divergence_4_vurderer_flytting_is_a_closed_word_set_not_a_boolean():
for word in kandidat_schema.FLYTTING_ORD:
assert kandidat_schema.validate(divergent(flytting=word))["gyldig"] is True
report = kandidat_schema.validate(divergent(flytting="true"))
assert report["gyldig"] is False
assert "geografi.vurderer_flytting" in keys_in(report["feil"])
# The grammar has no boolean, so `true` is the string "true" and not a
# value this key may hold.
melding = [f["melding"] for f in report["feil"]
if f["nokkel"] == "geografi.vurderer_flytting"][0]
assert "ja" in melding and "nei" in melding
def test_divergence_5_an_explicitly_unmeasured_outcome_is_valid():
report = kandidat_schema.validate(divergent())
assert report["gyldig"] is True
assert keys_in(report["feil"]) == []
# 5.1 asks each signature project for a measurable outcome. The schema
# checks that the section is there and in order, and stops: an honest
# "ikke målt" is worth more than an estimate invented to satisfy a
# validator, and telling the two apart is judgement, not structure.
assert "Signaturprosjekter" in report["seksjoner"]
def test_divergence_6_free_text_seniority_is_reported_not_enum_checked():
report = kandidat_schema.validate(divergent(min="prinsipal", maks="teknisk direktør"))
assert report["gyldig"] is True
# The ordering that makes the seniority filter more than decorative lives
# in scoring's rank table, not here; the schema hands the words on intact.
assert report["senioritet"] == {"min": "prinsipal", "maks": "teknisk direktør"}
def test_divergence_7_a_prose_breadth_bullet_is_not_required_to_be_a_skill():
report = kandidat_schema.validate(divergent())
assert report["gyldig"] is True
# "Teknologisk breddeforståelse og intuisjon" is a disposition, not a
# named field. The body is judgement (5.1), so nothing here parses inside
# a section -- and this test is what keeps a later hand from adding it.
assert report["seksjoner"] == list(kandidat_schema.SEKSJONER)
def test_the_real_profile_shape_validates_clean_end_to_end():
# Assumption 1 discharged, mechanically and in public: a profile carrying
# every structural feature of the operator's real file -- H1 title, H3
# subsections, comma lists, slash alternatives, a top-level extension key,
# free-text seniority, no `vekter` -- validates with zero errors and lands
# on the default weight vector.
report = kandidat_schema.validate(divergent())
assert report["gyldig"] is True
assert report["feil"] == []
assert report["vekter_kilde"] == "default"
assert report["vekter"] == kandidat_schema.STANDARDVEKTER
assert json.loads(kandidat_schema.report_json(report)) == report