feat(m1): add strict frontmatter parser with documented grammar
This commit is contained in:
parent
8328d487a9
commit
a0874a5e17
2 changed files with 413 additions and 0 deletions
127
tests/test_frontmatter.py
Normal file
127
tests/test_frontmatter.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"""The strict frontmatter grammar for kandidat.md and sak.md (plan Step 4).
|
||||
|
||||
Risk H7: this is a hand-rolled parser sitting on the hard-filter path. A YAML
|
||||
runtime is not allowed, so the grammar has to be small enough to hold in the
|
||||
head and loud enough that nothing it does not understand slips past as a
|
||||
default. Every test below is therefore about a refusal or about preservation:
|
||||
what the parser accepts, and what it must never quietly swallow.
|
||||
|
||||
The preservation half matters as much as the refusal half. `geografi.base` is
|
||||
a Norwegian place name; if a round trip through the parser normalises it from
|
||||
NFC to NFD or escapes it to an ASCII sequence, the file on disk stops being
|
||||
the file the operator wrote. paths.slug normalises on purpose -- this module
|
||||
must not.
|
||||
|
||||
Style note: this file follows tests/test_paths.py.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from jobbsok_lib import frontmatter
|
||||
|
||||
|
||||
ROUND_TRIP = """---
|
||||
oppdatert: 2026-09-15
|
||||
geografi:
|
||||
base: Tromsø
|
||||
maks_reisetid_min: 45
|
||||
absolutte_nei:
|
||||
- Ærøy-prosjektet
|
||||
- nattskift på Målselv
|
||||
---
|
||||
Kjerne: rådgiver i Ålesund.
|
||||
"""
|
||||
|
||||
|
||||
def test_nested_mapping_and_scalar_types():
|
||||
meta, body = frontmatter.parse(
|
||||
"---\n"
|
||||
"oppdatert: 2026-09-15\n"
|
||||
"geografi:\n"
|
||||
" base: Bergen\n"
|
||||
" maks_reisetid_min: 45\n"
|
||||
" vurderer_flytting: nei\n"
|
||||
"---\n"
|
||||
"brodtekst\n"
|
||||
)
|
||||
assert meta["oppdatert"] == "2026-09-15"
|
||||
assert meta["geografi"] == {
|
||||
"base": "Bergen",
|
||||
"maks_reisetid_min": 45,
|
||||
"vurderer_flytting": "nei",
|
||||
}
|
||||
# An integer arrives as an int, not as the string that spelled it: the
|
||||
# scoring path compares it numerically.
|
||||
assert isinstance(meta["geografi"]["maks_reisetid_min"], int)
|
||||
assert body == "brodtekst\n"
|
||||
|
||||
|
||||
def test_flat_list_of_quoted_and_unquoted_items():
|
||||
meta, _ = frontmatter.parse(
|
||||
"---\n"
|
||||
"absolutte_nei:\n"
|
||||
" - salg\n"
|
||||
' - "turnus, med helg"\n'
|
||||
" - 3\n"
|
||||
"---\n"
|
||||
)
|
||||
assert meta["absolutte_nei"] == ["salg", "turnus, med helg", 3]
|
||||
|
||||
|
||||
def test_empty_document_has_no_frontmatter_and_is_not_an_error():
|
||||
# Absent frontmatter is not this module's error to raise -- Step 8's
|
||||
# contract check is where a kandidat.md without frontmatter is rejected.
|
||||
# Here it is simply an empty mapping and an untouched body.
|
||||
assert frontmatter.parse("") == ({}, "")
|
||||
assert frontmatter.parse("bare brodtekst\n") == ({}, "bare brodtekst\n")
|
||||
|
||||
|
||||
def test_hash_is_a_comment_only_at_line_start():
|
||||
meta, _ = frontmatter.parse(
|
||||
"---\n"
|
||||
"# denne linjen er en kommentar\n"
|
||||
"notat: rolle nr # 3, ikke nr 4\n"
|
||||
"---\n"
|
||||
)
|
||||
assert "#" not in meta
|
||||
# The hash inside the value is data. Trimming it would silently rewrite
|
||||
# what the operator wrote, which is the failure mode this test pins.
|
||||
assert meta["notat"] == "rolle nr # 3, ikke nr 4"
|
||||
|
||||
|
||||
def test_unterminated_fence_raises_with_the_line_number():
|
||||
with pytest.raises(frontmatter.FrontmatterError) as caught:
|
||||
frontmatter.parse("---\noppdatert: 2026-09-15\ngeografi:\n base: Bodø\n")
|
||||
assert caught.value.line == 1
|
||||
assert "---" in str(caught.value)
|
||||
|
||||
|
||||
def test_tab_indentation_is_refused():
|
||||
with pytest.raises(frontmatter.FrontmatterError) as caught:
|
||||
frontmatter.parse("---\ngeografi:\n\tbase: Bodø\n---\n")
|
||||
assert caught.value.line == 3
|
||||
assert "tab" in str(caught.value).lower()
|
||||
|
||||
|
||||
def test_duplicate_key_is_refused_rather_than_last_wins():
|
||||
# Last-wins is the dangerous default: a profile with two salary floors
|
||||
# would silently score against whichever came last.
|
||||
with pytest.raises(frontmatter.FrontmatterError) as caught:
|
||||
frontmatter.parse("---\nlonn:\n gulv_nok: 750000\n gulv_nok: 650000\n---\n")
|
||||
assert caught.value.line == 4
|
||||
|
||||
|
||||
def test_norwegian_characters_survive_a_round_trip_byte_identically():
|
||||
meta, body = frontmatter.parse(ROUND_TRIP)
|
||||
assert meta["geografi"]["base"] == "Tromsø"
|
||||
assert meta["absolutte_nei"][1] == "nattskift på Målselv"
|
||||
rendered = frontmatter.render(meta, body)
|
||||
assert rendered == ROUND_TRIP
|
||||
assert rendered.encode("utf-8") == ROUND_TRIP.encode("utf-8")
|
||||
|
||||
|
||||
def test_a_line_outside_the_grammar_names_its_line_number():
|
||||
with pytest.raises(frontmatter.FrontmatterError) as caught:
|
||||
frontmatter.parse("---\noppdatert: 2026-09-15\ndette er ikke en nokkel\n---\n")
|
||||
assert caught.value.line == 3
|
||||
assert "3" in str(caught.value)
|
||||
Loading…
Add table
Add a link
Reference in a new issue