"""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: 1050000 onsket_nok: 1150000 kommentar: Gulvet avviser mekanisk. Ønsket nivå vekter, avviser ikke. ansettelsesform: aksepterer: %(aksepterer)s avviser: åremål/engasjement, vikariat absolutte_nei: - personalansvar / linjeledelse - salg og kundeanskaffelse 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": "seniorrådgiver", "maks": "fagdirektør/sjefsarkitekt", } 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 "fagdirektø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"] == [ ["personalansvar", "linjeledelse"], ["salg og kundeanskaffelse"], ] 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