feat(launcher-test): cover frontmatter + markdown parsers

Extends run_tests from 12 -> ~46 asserts: 10 frontmatter cases (simple
field, no-frontmatter passthrough, numeric field, multi-line body,
non-string input, empty meta) and ~25 markdown cases (headers,
bullets, ordered, images, bold/italic/code spans, paragraph joining,
mixed mid-block). frontmatter.lua and markdown.lua are byte-identical
copies of the launcher's helpers; smoke.sh enforces no drift via
git hash-object.
This commit is contained in:
Calic
2026-06-11 01:30:43 +02:00
parent 019b18d621
commit f1d0aec9a5
4 changed files with 400 additions and 17 deletions

68
frontmatter.lua Normal file
View File

@@ -0,0 +1,68 @@
-- frontmatter.lua — JSON-Frontmatter parser for manifest.launcher.md
-- Splits on the first standalone '---' line. Everything before goes to
-- depf._json_decode; everything after (after a single LF) is body.
-- Loud-Error semantics: parse failure returns nil + err-string.
local M = {}
-- depf is required lazily so this file can be dofile'd into a test-lib
-- context where lib-management.dep-fetcher is not on the dep list. The
-- launcher main module does require it eagerly; the test-lib monkeys
-- a stub decoder into _G before calling fm.parse.
local function get_decoder()
if _G.LAUNCHER_FRONTMATTER_DECODER then
return _G.LAUNCHER_FRONTMATTER_DECODER
end
local ok, depf = pcall(require, "lib-management.dep-fetcher")
if ok and depf and depf._json_decode then return depf._json_decode end
return nil
end
-- Splits the content at the first line equal to '---'. The first such
-- line ends the JSON region; lines before it are JSON, lines after
-- are body. Both regions are LF-joined back into strings.
local function split_at_separator(content)
local lines = {}
for line in (content .. "\n"):gmatch("([^\n]*)\n") do
lines[#lines + 1] = line
end
-- A valid file starts with '---' (no leading whitespace).
if lines[1] ~= "---" then return nil end
local sep_idx = nil
for i = 2, #lines do
if lines[i] == "---" then sep_idx = i; break end
end
if not sep_idx then return nil end
local json_lines = {}
for i = 2, sep_idx - 1 do json_lines[#json_lines + 1] = lines[i] end
local body_lines = {}
for i = sep_idx + 1, #lines do body_lines[#body_lines + 1] = lines[i] end
-- Drop the trailing empty entry produced by the final LF appended above,
-- so the body matches user expectation ('body text' rather than 'body text\n').
if #body_lines > 0 and body_lines[#body_lines] == "" then
body_lines[#body_lines] = nil
end
return table.concat(json_lines, "\n"), table.concat(body_lines, "\n")
end
function M.parse(content)
if type(content) ~= "string" then
return nil, nil, "frontmatter.parse: content must be string"
end
local json_str, body = split_at_separator(content)
if not json_str then
-- No frontmatter — treat the whole content as body, empty meta.
return {}, content
end
local decode = get_decoder()
if not decode then
return nil, nil, "frontmatter.parse: no JSON decoder available"
end
local meta, derr = decode(json_str)
if not meta then
return nil, nil, "frontmatter.parse: " .. tostring(derr)
end
return meta, body
end
return M

113
init.lua
View File

@@ -1,34 +1,119 @@
-- lib-management.launcher-test — P.3.4
-- 11 assertions: 5 FSM transitions + 1 FSM stability + 5 Engine-Surface existence.
-- (measure_text runtime-behavior check dropped — needs Raylib font, not loaded in headless test-mode.)
-- Pattern follows P.3.1 Test-Module-Pattern; TAP output via engine.test.*
-- FSM-Source: byte-identical Kopie aus modules/lib-management.launcher/fsm.lua.
-- Sporel-Resolver lehnt module-als-dep ab (nur lib-deps werden resolved),
-- daher hat dieses Test-Modul seine eigene fsm.lua. Drift wird via Smoke-
-- diff-check verhindert (siehe smoke.sh P0-S15-pre).
local M = {}
local fsm = dofile(engine.module.dir_of("lib-management.launcher-test") .. "/fsm.lua")
local DIR = engine.module.dir_of("lib-management.launcher-test")
local fsm = dofile(DIR .. "/fsm.lua")
-- Inject a tiny JSON decoder for the headless test context — depf is not
-- a lib-dep of this test-lib. Same shape contract as
-- lib-management.dep-fetcher._json_decode: returns (tbl, err).
_G.LAUNCHER_FRONTMATTER_DECODER = function(json_str)
-- Minimal flat-object decoder: supports {"key":"value", "n":42}.
-- Tests only feed it shallow JSON.
local t = {}
for k, v in json_str:gmatch('"([%w_]+)"%s*:%s*"([^"]*)"') do
t[k] = v
end
for k, n in json_str:gmatch('"([%w_]+)"%s*:%s*(%-?%d+%.?%d*)') do
t[k] = tonumber(n)
end
return t
end
local fm = dofile(DIR .. "/frontmatter.lua")
local md = dofile(DIR .. "/markdown.lua")
function M.run_tests(ctx)
-- FSM transitions (5)
-- ----- FSM transitions (unchanged) -----
engine.test.equals(fsm.next(fsm.STATE_LIST, "esc"), fsm.STATE_QUIT_CONFIRM, "list+esc -> quit_confirm")
engine.test.equals(fsm.next(fsm.STATE_QUIT_CONFIRM, "esc"), fsm.STATE_LIST, "quit_confirm+esc -> list")
engine.test.equals(fsm.next(fsm.STATE_QUIT_CONFIRM, "enter"), fsm.EXIT, "quit_confirm+enter -> exit")
engine.test.equals(fsm.next(fsm.STATE_QUIT_CONFIRM, "click_yes"), fsm.EXIT, "quit_confirm+click_yes -> exit")
engine.test.equals(fsm.next(fsm.STATE_QUIT_CONFIRM, "click_no"), fsm.STATE_LIST, "quit_confirm+click_no -> list")
-- FSM stability (2)
engine.test.equals(fsm.next(fsm.STATE_LIST, "click_module"), fsm.STATE_LIST, "list+click_module -> list (switch is side-effect)")
engine.test.equals(fsm.next("garbage_state", "esc"), "garbage_state", "unknown state passes through unchanged")
-- Engine-surface existence (6)
-- ----- Engine-surface existence (unchanged) -----
engine.test.assert(type(engine.module.list) == "function", "engine.module.list is function")
engine.test.assert(type(engine.module.dir_of) == "function", "engine.module.dir_of is function")
engine.test.assert(type(engine.render.draw_text) == "function", "engine.render.draw_text is function")
engine.test.assert(type(engine.render.measure_text) == "function", "engine.render.measure_text is function")
engine.test.assert(type(engine.module.list()) == "table", "engine.module.list() returns table")
-- ----- Frontmatter (10) -----
local meta, body = fm.parse('---\n"name":"Test"\n---\nbody text\n')
engine.test.equals(meta.name, "Test", "fm: simple name field")
engine.test.equals(body, "body text", "fm: body after separator")
meta, body = fm.parse("no frontmatter here")
engine.test.equals(next(meta), nil, "fm: empty meta when no frontmatter")
engine.test.equals(body, "no frontmatter here", "fm: full content as body")
meta, body = fm.parse('---\n"n":42\n---\n\nLine 1\nLine 2\n')
engine.test.equals(meta.n, 42, "fm: numeric field")
engine.test.equals(body:find("Line 1") ~= nil, true, "fm: body multi-line")
local m2, b2, err = fm.parse(42)
engine.test.equals(m2, nil, "fm: non-string returns nil")
engine.test.assert(err and err:find("string"), "fm: non-string error mentions 'string'")
meta, body = fm.parse('---\n---\nempty meta\n')
engine.test.equals(next(meta), nil, "fm: explicit empty meta")
engine.test.equals(body, "empty meta", "fm: body after empty meta")
-- ----- Markdown parse (25) -----
local blocks = md.parse("# Header\n\nparagraph text\n")
engine.test.equals(#blocks, 2, "md: header + paragraph -> 2 blocks")
engine.test.equals(blocks[1].type, "h1", "md: '#' -> h1")
engine.test.equals(blocks[1].text, "Header", "md: h1 text stripped")
engine.test.equals(blocks[2].type, "p", "md: paragraph block-type")
blocks = md.parse("## H2\n### H3\n")
engine.test.equals(blocks[1].type, "h2", "md: '##' -> h2")
engine.test.equals(blocks[2].type, "h3", "md: '###' -> h3")
blocks = md.parse("- item one\n- item two\n")
engine.test.equals(#blocks, 2, "md: two bullet items")
engine.test.equals(blocks[1].type, "bullet", "md: '-' -> bullet")
blocks = md.parse("1. first\n2. second\n")
engine.test.equals(blocks[1].type, "ordered", "md: '1.' -> ordered")
engine.test.equals(blocks[1].index, 1, "md: ordered index parsed")
engine.test.equals(blocks[2].index, 2, "md: ordered index increments")
blocks = md.parse("![alt text](teaser.png)\n")
engine.test.equals(blocks[1].type, "img", "md: '!\\[\\]\\(\\)' -> img")
engine.test.equals(blocks[1].alt, "alt text", "md: img alt extracted")
engine.test.equals(blocks[1].src, "teaser.png", "md: img src extracted")
blocks = md.parse("plain **bold** word\n")
engine.test.equals(blocks[1].type, "p", "md: bold-in-paragraph stays p")
local has_bold = false
for _, s in ipairs(blocks[1].spans) do if s.kind == "bold" then has_bold = true end end
engine.test.assert(has_bold, "md: bold span recognised")
blocks = md.parse("plain *italic* word\n")
local has_italic = false
for _, s in ipairs(blocks[1].spans) do if s.kind == "italic" then has_italic = true end end
engine.test.assert(has_italic, "md: italic span recognised")
blocks = md.parse("inline `code` here\n")
local has_code = false
for _, s in ipairs(blocks[1].spans) do if s.kind == "code" then has_code = true end end
engine.test.assert(has_code, "md: code span recognised")
blocks = md.parse("line one\nline two\n\nsecond para\n")
engine.test.equals(#blocks, 2, "md: blank line breaks paragraph")
engine.test.equals(blocks[1].spans[1].text:find("line one") ~= nil, true,
"md: paragraph 1 joins lines")
engine.test.equals(blocks[2].spans[1].text:find("second para") ~= nil, true,
"md: paragraph 2 separated")
blocks = md.parse("")
engine.test.equals(#blocks, 0, "md: empty body -> no blocks")
blocks = md.parse(nil)
engine.test.equals(#blocks, 0, "md: nil body -> no blocks")
blocks = md.parse("# A\n\n- one\n- two\n\nparagraph\n")
engine.test.equals(blocks[1].type, "h1", "md: mixed h1+bullets+p: h1 first")
engine.test.equals(blocks[2].type, "bullet", "md: mixed: bullet second")
engine.test.equals(blocks[3].type, "bullet", "md: mixed: bullet third")
engine.test.equals(blocks[4].type, "p", "md: mixed: p fourth")
end
return M

View File

@@ -1,7 +1,7 @@
{
"id": "lib-management.launcher-test",
"role": "test",
"version": "0.1.0",
"version": "0.2.0",
"api_min": "0.1",
"deps": []
}

230
markdown.lua Normal file
View File

@@ -0,0 +1,230 @@
-- markdown.lua — M3-subset parser + renderer for launcher description bodies.
-- Supports:
-- #/##/### headers (H1=24pt, H2=20pt, H3=16pt; per L-Q3)
-- blank-line-separated paragraphs (body=14pt)
-- bold (**text**) and italic (*text*) inline spans
-- inline images: ![alt](rel/path.png)
-- bullet lists (- item) and ordered lists (1. item)
-- inline code (`code`)
-- Does NOT support: tables, blockquotes, links, code blocks, headers >H3.
local M = {}
local H1_SIZE = 24
local H2_SIZE = 20
local H3_SIZE = 16
local BODY_SIZE = 14
local LINE_GAP = 4
local PARA_GAP = 12
local BULLET_INDENT = 18
local COLOR_TEXT = 0xE0E0E0FF
local COLOR_DIM = 0xA0A0A0FF
local COLOR_CODE = 0x80C0FFFF
-- ---- parse ----------------------------------------------------------
-- Returns a flat list of block tables. Each block:
-- {type="h1"|"h2"|"h3", text=string}
-- {type="p", spans={...}}
-- {type="img", src=string, alt=string}
-- {type="bullet", spans={...}}
-- {type="ordered", index=int, spans={...}}
local function parse_spans(line)
-- Tokenize into runs of {kind, text}: kind in plain|bold|italic|code.
local spans = {}
local i = 1
local n = #line
while i <= n do
local b, e, cap
-- Bold first (greedier delimiter).
b, e, cap = line:find("%*%*([^%*]+)%*%*", i)
if b == i then
spans[#spans + 1] = { kind = "bold", text = cap }
i = e + 1
else
b, e, cap = line:find("%*([^%*]+)%*", i)
if b == i then
spans[#spans + 1] = { kind = "italic", text = cap }
i = e + 1
else
b, e, cap = line:find("`([^`]+)`", i)
if b == i then
spans[#spans + 1] = { kind = "code", text = cap }
i = e + 1
else
-- Plain run up to the next delimiter or end-of-string.
local nxt = n + 1
for _, pat in ipairs({ "%*%*", "%*", "`" }) do
local nb = line:find(pat, i)
if nb and nb < nxt then nxt = nb end
end
spans[#spans + 1] = { kind = "plain", text = line:sub(i, nxt - 1) }
i = nxt
end
end
end
end
return spans
end
function M.parse(body)
local blocks = {}
if type(body) ~= "string" then return blocks end
-- Split into lines preserving blank-line boundaries.
local lines = {}
for line in (body .. "\n"):gmatch("([^\n]*)\n") do
lines[#lines + 1] = line
end
local i = 1
while i <= #lines do
local line = lines[i]
if line:match("^%s*$") then
i = i + 1
else
-- Header?
local h3 = line:match("^### (.+)$")
local h2 = line:match("^## (.+)$")
local h1 = line:match("^# (.+)$")
if h1 then
blocks[#blocks + 1] = { type = "h1", text = h1 }
i = i + 1
elseif h2 then
blocks[#blocks + 1] = { type = "h2", text = h2 }
i = i + 1
elseif h3 then
blocks[#blocks + 1] = { type = "h3", text = h3 }
i = i + 1
else
-- Inline image alone on a line?
local alt, src = line:match("^!%[(.-)%]%((.-)%)%s*$")
if alt and src then
blocks[#blocks + 1] = { type = "img", alt = alt, src = src }
i = i + 1
else
-- Bullet?
local bullet = line:match("^%- (.+)$")
if bullet then
blocks[#blocks + 1] = { type = "bullet", spans = parse_spans(bullet) }
i = i + 1
else
-- Ordered?
local idx, item = line:match("^(%d+)%. (.+)$")
if idx and item then
blocks[#blocks + 1] = {
type = "ordered",
index = tonumber(idx),
spans = parse_spans(item),
}
i = i + 1
else
-- Paragraph: concatenate consecutive non-blank, non-special lines.
local para_lines = { line }
local j = i + 1
while j <= #lines do
local nxt = lines[j]
if nxt:match("^%s*$")
or nxt:match("^#")
or nxt:match("^%- ")
or nxt:match("^%d+%. ")
or nxt:match("^!%[") then
break
end
para_lines[#para_lines + 1] = nxt
j = j + 1
end
local joined = table.concat(para_lines, " ")
blocks[#blocks + 1] = { type = "p", spans = parse_spans(joined) }
i = j
end
end
end
end
end
end
return blocks
end
-- ---- render ---------------------------------------------------------
local function span_color(kind)
if kind == "code" then return COLOR_CODE end
return COLOR_TEXT
end
local function render_spans(spans, x, y, max_w, size)
-- Naive flow: split each span's text by spaces, wrap at max_w.
local cur_x = x
local cur_y = y
local _, line_h = engine.render.measure_text("Ay", size)
for _, sp in ipairs(spans) do
for word in sp.text:gmatch("%S+") do
local prefix = (cur_x == x) and "" or " "
local seg = prefix .. word
local w, _ = engine.render.measure_text(seg, size)
if cur_x + w > x + max_w and cur_x > x then
cur_y = cur_y + line_h + LINE_GAP
cur_x = x
seg = word
w, _ = engine.render.measure_text(seg, size)
end
engine.render.draw_text(seg, cur_x, cur_y, size, span_color(sp.kind))
cur_x = cur_x + w
end
end
return cur_y + line_h
end
function M.render(blocks, x, y, max_w, module_id_for_imgs)
local cur_y = y
for _, b in ipairs(blocks) do
if b.type == "h1" then
engine.render.draw_text(b.text, x, cur_y, H1_SIZE, COLOR_TEXT)
local _, h = engine.render.measure_text(b.text, H1_SIZE)
cur_y = cur_y + h + PARA_GAP
elseif b.type == "h2" then
engine.render.draw_text(b.text, x, cur_y, H2_SIZE, COLOR_TEXT)
local _, h = engine.render.measure_text(b.text, H2_SIZE)
cur_y = cur_y + h + PARA_GAP
elseif b.type == "h3" then
engine.render.draw_text(b.text, x, cur_y, H3_SIZE, COLOR_TEXT)
local _, h = engine.render.measure_text(b.text, H3_SIZE)
cur_y = cur_y + h + PARA_GAP
elseif b.type == "p" then
local ny = render_spans(b.spans, x, cur_y, max_w, BODY_SIZE)
cur_y = ny + PARA_GAP
elseif b.type == "bullet" then
engine.render.draw_text("", x, cur_y, BODY_SIZE, COLOR_TEXT)
local ny = render_spans(b.spans, x + BULLET_INDENT, cur_y,
max_w - BULLET_INDENT, BODY_SIZE)
cur_y = ny + LINE_GAP
elseif b.type == "ordered" then
local prefix = tostring(b.index) .. "."
engine.render.draw_text(prefix, x, cur_y, BODY_SIZE, COLOR_TEXT)
local ny = render_spans(b.spans, x + BULLET_INDENT, cur_y,
max_w - BULLET_INDENT, BODY_SIZE)
cur_y = ny + LINE_GAP
elseif b.type == "img" then
-- module_id_for_imgs lets the launcher resolve "<rel>" inside
-- the module's own assets tree via engine.module.load_texture.
if module_id_for_imgs and engine.module.load_texture then
local tex = engine.module.load_texture(module_id_for_imgs, b.src)
if tex then
engine.render.draw_texture(tex, x, cur_y)
-- Texture height is not introspectable from Lua in v1;
-- assume a 16:9 max_w-wide image for layout.
cur_y = cur_y + math.floor(max_w * 9 / 16) + PARA_GAP
else
engine.render.draw_text("[image missing: " .. b.src .. "]",
x, cur_y, BODY_SIZE, COLOR_DIM)
local _, h = engine.render.measure_text("[image]", BODY_SIZE)
cur_y = cur_y + h + PARA_GAP
end
end
end
end
return cur_y
end
return M