feat(m1): validate kandidat.md against the section 5.1 contract
This commit is contained in:
parent
b538b5c5f0
commit
ac7de63dc1
3 changed files with 737 additions and 0 deletions
360
scripts/kandidat_schema.py
Normal file
360
scripts/kandidat_schema.py
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
"""Mechanical validation of `profil/kandidat.md` against build-brief 5.1.
|
||||
|
||||
The brief's own instruction about this file is the one that shapes everything
|
||||
here: *"The operator will supply an existing kandidat.md. Treat its structure
|
||||
as the contract and validate against it rather than regenerating it."* So this
|
||||
module reports; it never rewrites, and it never refuses a profile for being
|
||||
richer than the contract anticipated. A missing required key is an error that
|
||||
names the key path. Anything the contract has no opinion about is a warning.
|
||||
|
||||
Reading the operator's real file on 2026-09-04 produced seven places where the
|
||||
file and 5.1 disagree. Six are settled here, and each is a decision rather
|
||||
than an accident, so each is written down where it was taken.
|
||||
|
||||
**1. A mapping value that has to hold several items.** The operator accepts
|
||||
two employment forms and rejects two more, but the Step 4 grammar allows only
|
||||
a scalar beneath a mapping key (`child := " " KEY ":" SP scalar`). Three ways
|
||||
out were open: widen the grammar, lift the key to the top level, or make the
|
||||
separator a contract. The separator wins. Widening the grammar puts a second
|
||||
parse mode on the hand-rolled parser that sits on the hard-filter path, and
|
||||
that parser's whole value is that it is small enough to hold in the head
|
||||
(risk H7); lifting the key changes the shape of a file the operator already
|
||||
wrote. So: **a comma splits a scalar into items, a slash splits one item into
|
||||
alternatives, and whitespace inside an alternative is a phrase.** One rule,
|
||||
applied identically to `ansettelsesform.aksepterer`, `ansettelsesform.avviser`
|
||||
and each entry of `absolutte_nei` -- which is why `åremål/engasjement, vikariat`
|
||||
reads as two rejected forms, the first of which has two names, and
|
||||
`personalansvar / linjeledelse` reads as one no with two names. The rule costs
|
||||
the ability to write a comma inside an employment form; no employment form has
|
||||
one.
|
||||
|
||||
**2. Keys outside 5.1.** An unknown key is a warning, never an error: the
|
||||
operator owns this file. But a key that scoring actually reads must not look
|
||||
like the typo it is otherwise indistinguishable from, so the small set in
|
||||
:data:`HONORERTE_UTVIDELSER` warns *and says it is honoured*.
|
||||
|
||||
**3.** Travel time as a soft filter is scoring's decision, and lives in
|
||||
`scripts/vurdering.py`.
|
||||
|
||||
**4. `vurderer_flytting` is a word, not a boolean.** The grammar has no
|
||||
boolean -- `render` refuses one outright -- so the value is the word the
|
||||
operator wrote, and the check is a closed word set rather than a type.
|
||||
|
||||
**5. An explicitly unmeasured outcome.** 5.1 asks each signature project for a
|
||||
measurable outcome. One of the operator's has none, and says so instead of
|
||||
offering an estimate. Structure is checked here; whether an outcome is real is
|
||||
judgement, and a validator that demanded a number would be a validator that
|
||||
rewarded inventing one. So the body is checked for its sections and their
|
||||
order, and for nothing inside them.
|
||||
|
||||
**6. `senioritet.min` / `.maks` are free text.** 5.1 gives no enum, and the
|
||||
operator's values are `seniorrådgiver` and `fagdirektør/sjefsarkitekt`. The
|
||||
ordering that makes the seniority filter more than decorative is a rank table,
|
||||
which belongs to scoring; here the words are carried through intact.
|
||||
|
||||
**7. A breadth bullet need not name a skill.** Nothing in this module parses
|
||||
inside a section, which is exactly what lets *"teknologisk breddeforståelse og
|
||||
intuisjon"* stand as breadth.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from jobbsok_lib import frontmatter, paths
|
||||
|
||||
#: Body sections from build-brief 5.1, in the order the contract requires.
|
||||
SEKSJONER = (
|
||||
"Kjerne",
|
||||
"Kompetanse",
|
||||
"Retning",
|
||||
"Signaturprosjekter",
|
||||
"Kjente svakheter",
|
||||
"Formuleringer som virker",
|
||||
"Profilgap",
|
||||
)
|
||||
|
||||
#: Required frontmatter keys from 5.1. A tuple of leaves means a nested block;
|
||||
#: `None` means a top-level scalar or list. Seventeen keys counting leaves.
|
||||
PAAKREVD = (
|
||||
("oppdatert", None),
|
||||
("geografi", ("base", "maks_reisetid_min", "hjemmekontor_min_dager", "vurderer_flytting")),
|
||||
("lonn", ("gulv_nok", "onsket_nok", "kommentar")),
|
||||
("ansettelsesform", ("aksepterer", "avviser")),
|
||||
("absolutte_nei", None),
|
||||
("senioritet", ("min", "maks")),
|
||||
)
|
||||
|
||||
#: Keys whose value the hard filters compare numerically.
|
||||
HELTALL = ("geografi.maks_reisetid_min", "geografi.hjemmekontor_min_dager",
|
||||
"lonn.gulv_nok", "lonn.onsket_nok")
|
||||
|
||||
#: The closed word set for `geografi.vurderer_flytting` (divergence 4).
|
||||
FLYTTING_ORD = ("ja", "nei", "kanskje")
|
||||
|
||||
#: Scoring criteria, and the weights shipped when the profile names none.
|
||||
#: Percentages so the vector reads as a distribution at a glance; scoring
|
||||
#: normalises by the sum, so any non-negative integers would do.
|
||||
STANDARDVEKTER = {
|
||||
"fagomrade": 40,
|
||||
"oppgavetype": 30,
|
||||
"teknologi": 20,
|
||||
"selskapstype": 10,
|
||||
}
|
||||
|
||||
#: Keys outside 5.1 that scoring nevertheless reads, with the reason. Warned
|
||||
#: about like any unknown key, but named as honoured so an extension the
|
||||
#: plugin acts on is distinguishable from a misspelling it ignores.
|
||||
HONORERTE_UTVIDELSER = {
|
||||
"arbeidssteder": (
|
||||
"utenfor 5.1, men lest av vurdering: en eksplisitt liste over "
|
||||
"akseptable arbeidssteder gjoer reisetid til en advarsel for disse "
|
||||
"stedene"
|
||||
),
|
||||
}
|
||||
|
||||
#: Additive key from operator decision 14.3.
|
||||
VEKTNOKKEL = "vekter"
|
||||
|
||||
KJENTE_TOPPNOKLER = tuple(key for key, _ in PAAKREVD) + (VEKTNOKKEL,)
|
||||
|
||||
FENCE = "```"
|
||||
|
||||
|
||||
def validate(text):
|
||||
"""Validate a `kandidat.md` document and return the report.
|
||||
|
||||
Malformed frontmatter is not a finding -- it is
|
||||
:class:`~jobbsok_lib.frontmatter.FrontmatterError`, raised with the
|
||||
offending line number, because a document that did not parse has no
|
||||
findings to report on.
|
||||
"""
|
||||
meta, body = frontmatter.parse(text)
|
||||
feil = []
|
||||
advarsler = []
|
||||
|
||||
_sjekk_paakrevde(meta, feil)
|
||||
_sjekk_typer(meta, feil)
|
||||
_sjekk_ukjente(meta, advarsler)
|
||||
|
||||
seksjoner = _seksjoner(body)
|
||||
_sjekk_seksjoner(seksjoner, feil)
|
||||
|
||||
vekter, kilde = _vekter(meta, feil, advarsler)
|
||||
|
||||
return {
|
||||
"gyldig": not feil,
|
||||
"feil": feil,
|
||||
"advarsler": advarsler,
|
||||
"seksjoner": seksjoner,
|
||||
"senioritet": _blokk(meta, "senioritet"),
|
||||
"ansettelsesform": {
|
||||
felt: del_skalar(_blokk(meta, "ansettelsesform").get(felt))
|
||||
for felt in ("aksepterer", "avviser")
|
||||
},
|
||||
"absolutte_nei": [del_alternativer(item) for item in _liste(meta, "absolutte_nei")],
|
||||
"arbeidssteder": _liste(meta, "arbeidssteder"),
|
||||
"vekter_kilde": kilde,
|
||||
"vekter": vekter,
|
||||
}
|
||||
|
||||
|
||||
def validate_file(root, *parts):
|
||||
"""Validate the profile at ``parts`` under ``root``, via ``paths.safe_join``."""
|
||||
meta, body = frontmatter.read(root, *parts)
|
||||
return validate(frontmatter.render(meta, body))
|
||||
|
||||
|
||||
def report_json(report):
|
||||
"""Render a report as the canonical JSON the golden files hold.
|
||||
|
||||
``ensure_ascii=False``: the report quotes the operator's own words back,
|
||||
and `Sørdal` escaped to a `\\u00f8` sequence is no longer the word that
|
||||
was written.
|
||||
"""
|
||||
return json.dumps(report, ensure_ascii=False, indent=2) + "\n"
|
||||
|
||||
|
||||
def del_skalar(value):
|
||||
"""Split a scalar into items, then each item into alternatives.
|
||||
|
||||
The divergence-1 contract: comma between items, slash between alternatives
|
||||
for the same item. ``None`` and the empty string are an empty list, not a
|
||||
list holding an empty name.
|
||||
"""
|
||||
if value is None:
|
||||
return []
|
||||
items = [part.strip() for part in str(value).split(",")]
|
||||
return [del_alternativer(item) for item in items if item]
|
||||
|
||||
|
||||
def del_alternativer(item):
|
||||
"""Split one item on slashes into its alternative spellings."""
|
||||
parts = [part.strip() for part in str(item).split("/")]
|
||||
return [part for part in parts if part]
|
||||
|
||||
|
||||
def normaliser(text):
|
||||
"""Fold a term for comparison: lowercase ASCII, hyphen-separated tokens.
|
||||
|
||||
Goes through :func:`paths.slug` so the Norwegian folds are defined in one
|
||||
place. A term that folds to nothing (punctuation only) returns ``""``
|
||||
rather than raising, because a comparison helper must not be the thing
|
||||
that stops a run.
|
||||
"""
|
||||
try:
|
||||
return paths.slug(text)
|
||||
except ValueError:
|
||||
return ""
|
||||
|
||||
|
||||
def _sjekk_paakrevde(meta, feil):
|
||||
for key, leaves in PAAKREVD:
|
||||
if key not in meta:
|
||||
_feil(feil, key, "paakrevd noekkel mangler (build-brief 5.1)")
|
||||
# Naming only the block would leave the operator guessing which
|
||||
# leaves to write, so every leaf is named too.
|
||||
for leaf in leaves or ():
|
||||
_feil(feil, "%s.%s" % (key, leaf), "paakrevd noekkel mangler (build-brief 5.1)")
|
||||
continue
|
||||
if leaves is None:
|
||||
continue
|
||||
blokk = meta[key]
|
||||
if not isinstance(blokk, dict):
|
||||
_feil(feil, key, "forventet et nestet kart med %s" % ", ".join(leaves))
|
||||
continue
|
||||
for leaf in leaves:
|
||||
if leaf not in blokk:
|
||||
_feil(feil, "%s.%s" % (key, leaf), "paakrevd noekkel mangler (build-brief 5.1)")
|
||||
|
||||
|
||||
def _sjekk_typer(meta, feil):
|
||||
for path in HELTALL:
|
||||
key, leaf = path.split(".")
|
||||
blokk = meta.get(key)
|
||||
if not isinstance(blokk, dict) or leaf not in blokk:
|
||||
continue
|
||||
if not isinstance(blokk[leaf], int):
|
||||
_feil(feil, path,
|
||||
"hardfiltrene sammenligner denne numerisk; %r er ikke et heltall"
|
||||
% (blokk[leaf],))
|
||||
|
||||
if isinstance(meta.get("absolutte_nei"), dict):
|
||||
_feil(feil, "absolutte_nei", "forventet en liste, fikk et kart")
|
||||
|
||||
geografi = meta.get("geografi")
|
||||
if isinstance(geografi, dict) and "vurderer_flytting" in geografi:
|
||||
verdi = geografi["vurderer_flytting"]
|
||||
if str(verdi).strip().lower() not in FLYTTING_ORD:
|
||||
_feil(feil, "geografi.vurderer_flytting",
|
||||
"grammatikken har ingen boolsk verdi; skriv %s (fikk %r)"
|
||||
% (", ".join(FLYTTING_ORD), verdi))
|
||||
|
||||
|
||||
def _sjekk_ukjente(meta, advarsler):
|
||||
for key in meta:
|
||||
if key in KJENTE_TOPPNOKLER:
|
||||
continue
|
||||
if key in HONORERTE_UTVIDELSER:
|
||||
_advarsel(advarsler, key, HONORERTE_UTVIDELSER[key])
|
||||
else:
|
||||
_advarsel(advarsler, key,
|
||||
"utenfor build-brief 5.1; beholdt, men ingenting leser den")
|
||||
|
||||
for key, leaves in PAAKREVD:
|
||||
if leaves is None or not isinstance(meta.get(key), dict):
|
||||
continue
|
||||
for leaf in meta[key]:
|
||||
if leaf not in leaves:
|
||||
_advarsel(advarsler, "%s.%s" % (key, leaf),
|
||||
"utenfor build-brief 5.1; beholdt, men ingenting leser den")
|
||||
|
||||
|
||||
def _seksjoner(body):
|
||||
"""The `## ` headings of the body, in document order.
|
||||
|
||||
Only level two counts: the real profile opens with an H1 title and uses H3
|
||||
inside Kompetanse, Retning and Signaturprosjekter, and neither is a
|
||||
section in the 5.1 sense. Fenced blocks are skipped so a `##` inside an
|
||||
example is prose, not structure.
|
||||
"""
|
||||
funnet = []
|
||||
inne_i_fence = False
|
||||
for line in body.split("\n"):
|
||||
if line.startswith(FENCE):
|
||||
inne_i_fence = not inne_i_fence
|
||||
continue
|
||||
if inne_i_fence:
|
||||
continue
|
||||
if line.startswith("## ") and not line.startswith("### "):
|
||||
funnet.append(line[3:].strip())
|
||||
return funnet
|
||||
|
||||
|
||||
def _sjekk_seksjoner(funnet, feil):
|
||||
mangler = [name for name in SEKSJONER if name not in funnet]
|
||||
if mangler:
|
||||
_feil(feil, "seksjoner",
|
||||
"mangler seksjon(er): %s (build-brief 5.1)" % ", ".join(mangler))
|
||||
|
||||
kjente = [name for name in funnet if name in SEKSJONER]
|
||||
forventet = [name for name in SEKSJONER if name in funnet]
|
||||
if kjente != forventet:
|
||||
_feil(feil, "seksjoner",
|
||||
"seksjonene staar i rekkefoelgen %s; 5.1 krever %s"
|
||||
% (" > ".join(kjente), " > ".join(forventet)))
|
||||
|
||||
|
||||
def _vekter(meta, feil, advarsler):
|
||||
if VEKTNOKKEL not in meta:
|
||||
return dict(STANDARDVEKTER), "default"
|
||||
|
||||
raw = meta[VEKTNOKKEL]
|
||||
if not isinstance(raw, dict):
|
||||
_feil(feil, VEKTNOKKEL, "forventet et kart over kriterium: vekt")
|
||||
return dict(STANDARDVEKTER), "default"
|
||||
|
||||
vekter = {}
|
||||
for kriterium in STANDARDVEKTER:
|
||||
if kriterium not in raw:
|
||||
_advarsel(advarsler, "%s.%s" % (VEKTNOKKEL, kriterium),
|
||||
"ikke oppgitt; bruker standardvekten %d"
|
||||
% STANDARDVEKTER[kriterium])
|
||||
vekter[kriterium] = STANDARDVEKTER[kriterium]
|
||||
continue
|
||||
verdi = raw[kriterium]
|
||||
if not isinstance(verdi, int) or verdi < 0:
|
||||
_feil(feil, "%s.%s" % (VEKTNOKKEL, kriterium),
|
||||
"vekten maa vaere et heltall >= 0, fikk %r" % (verdi,))
|
||||
vekter[kriterium] = STANDARDVEKTER[kriterium]
|
||||
continue
|
||||
vekter[kriterium] = verdi
|
||||
|
||||
for kriterium in raw:
|
||||
if kriterium not in STANDARDVEKTER:
|
||||
_advarsel(advarsler, "%s.%s" % (VEKTNOKKEL, kriterium),
|
||||
"ukjent kriterium; scoringen leser det ikke")
|
||||
|
||||
if sum(vekter.values()) == 0:
|
||||
_feil(feil, VEKTNOKKEL, "summen av vektene er 0; da har ingen kriterier vekt")
|
||||
return vekter, "profil"
|
||||
|
||||
|
||||
def _blokk(meta, key):
|
||||
verdi = meta.get(key)
|
||||
return dict(verdi) if isinstance(verdi, dict) else {}
|
||||
|
||||
|
||||
def _liste(meta, key):
|
||||
verdi = meta.get(key)
|
||||
if isinstance(verdi, list):
|
||||
return list(verdi)
|
||||
if verdi is None or isinstance(verdi, dict):
|
||||
return []
|
||||
return [verdi]
|
||||
|
||||
|
||||
def _feil(feil, nokkel, melding):
|
||||
feil.append({"nokkel": nokkel, "melding": melding})
|
||||
|
||||
|
||||
def _advarsel(advarsler, nokkel, melding):
|
||||
advarsler.append({"nokkel": nokkel, "melding": melding})
|
||||
46
tests/golden/kandidat-gyldig.report.json
Normal file
46
tests/golden/kandidat-gyldig.report.json
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"gyldig": true,
|
||||
"feil": [],
|
||||
"advarsler": [],
|
||||
"seksjoner": [
|
||||
"Kjerne",
|
||||
"Kompetanse",
|
||||
"Retning",
|
||||
"Signaturprosjekter",
|
||||
"Kjente svakheter",
|
||||
"Formuleringer som virker",
|
||||
"Profilgap"
|
||||
],
|
||||
"senioritet": {
|
||||
"min": "senior",
|
||||
"maks": "leder"
|
||||
},
|
||||
"ansettelsesform": {
|
||||
"aksepterer": [
|
||||
[
|
||||
"fast"
|
||||
]
|
||||
],
|
||||
"avviser": [
|
||||
[
|
||||
"vikariat"
|
||||
]
|
||||
]
|
||||
},
|
||||
"absolutte_nei": [
|
||||
[
|
||||
"turnus"
|
||||
],
|
||||
[
|
||||
"rendyrket salg"
|
||||
]
|
||||
],
|
||||
"arbeidssteder": [],
|
||||
"vekter_kilde": "default",
|
||||
"vekter": {
|
||||
"fagomrade": 40,
|
||||
"oppgavetype": 30,
|
||||
"teknologi": 20,
|
||||
"selskapstype": 10
|
||||
}
|
||||
}
|
||||
331
tests/test_kandidatprofil_schema.py
Normal file
331
tests/test_kandidatprofil_schema.py
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue