From f1d0aec9a52be04bb08718aac12be93000edeed6 Mon Sep 17 00:00:00 2001 From: Calic Date: Thu, 11 Jun 2026 01:30:43 +0200 Subject: [PATCH] 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. --- frontmatter.lua | 68 ++++++++++++++ init.lua | 117 ++++++++++++++++++++---- manifest.lib | 2 +- markdown.lua | 230 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 400 insertions(+), 17 deletions(-) create mode 100644 frontmatter.lua create mode 100644 markdown.lua diff --git a/frontmatter.lua b/frontmatter.lua new file mode 100644 index 0000000..bed2710 --- /dev/null +++ b/frontmatter.lua @@ -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 diff --git a/init.lua b/init.lua index 9826bc9..80ff5e5 100644 --- a/init.lua +++ b/init.lua @@ -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") + 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") - -- 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 diff --git a/manifest.lib b/manifest.lib index 439d9a5..65fa013 100644 --- a/manifest.lib +++ b/manifest.lib @@ -1,7 +1,7 @@ { "id": "lib-management.launcher-test", "role": "test", - "version": "0.1.0", + "version": "0.2.0", "api_min": "0.1", "deps": [] } diff --git a/markdown.lua b/markdown.lua new file mode 100644 index 0000000..a2d57d7 --- /dev/null +++ b/markdown.lua @@ -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 "" 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