src/flat-html-generator.chronological-links.test.lua
1#!/usr/bin/env luajit
2
3-- Guards the one promise the "chronological" link on every similar/different
4-- page makes: it names the chronological page that actually holds that poem.
5--
6-- Why this test exists. A full site build once shipped 694,530 chronological
7-- links that ALL pointed at page 1, because the poem-index-to-page mapping was
8-- computed correctly and then quietly not passed down the chain of five
9-- functions that lead to the link. Nothing failed; the formatter had a
10-- "guess 01" fallback that turned a missing argument into plausible-looking
11-- output. So these cases assert the PLUMBING, not just the formatting: if any
12-- function in that chain stops forwarding the mapping, a link here moves to the
13-- wrong page and the test fails.
14--
15-- Run directly: luajit src/flat-html-generator.chronological-links.test.lua
16-- Optional argument: project directory (defaults to this file's parent).
17
18-- Hard-coded project directory, overridable by the first argument, so the test
19-- runs from anywhere.
20local DIR = arg and arg[1] or nil
21
22-- {{{ local function setup_path()
23-- Resolves the module and library paths relative to this file, so the test does
24-- not care what directory it was launched from.
25local function setup_path()
26 local this = debug.getinfo(1, "S").source:sub(2)
27 local src_dir = this:match("(.*/)") or "./"
28 local project_dir = DIR or src_dir:gsub("src/$", "")
29 package.path = src_dir .. "?.lua;" .. project_dir .. "libs/?.lua;" .. package.path
30 return project_dir
31end
32-- }}}
33
34local PROJECT_DIR = setup_path()
35local generator = require("flat-html-generator")
36
37-- Ephemeral scratch space. The pages this test writes are throwaway artifacts,
38-- so they belong on the RAM tier rather than in the repository. Created here
39-- because the project tmp/ symlink may not exist on a fresh checkout.
40local SCRATCH = "/tmp/neocities-modernization/tmp/chronological-link-test"
41os.execute(string.format('mkdir -p "%s/similar" "%s/different"', SCRATCH, SCRATCH))
42
43local passed, failed = 0, 0
44
45-- {{{ local function check()
46local function check(name, condition, detail)
47 if condition then
48 passed = passed + 1
49 print(" ok " .. name)
50 else
51 failed = failed + 1
52 print(" FAIL " .. name .. (detail and (" -- " .. detail) or ""))
53 end
54end
55-- }}}
56
57-- {{{ local function make_poem()
58-- Minimal poem shaped the way the formatter reads it. poem_index is the field
59-- the chronological mapping is keyed by; everything else is filler so the
60-- renderer has something to draw.
61local function make_poem(index)
62 return {
63 poem_index = index,
64 id = index,
65 title = "Poem " .. index,
66 content = "a line of text for poem " .. index,
67 category = "messages",
68 filename = "messages/" .. index,
69 }
70end
71-- }}}
72
73-- {{{ local function make_ranking()
74-- The sorted-neighbour list the similar/different pages are built from.
75local function make_ranking(indices)
76 local ranking = {}
77 for _, index in ipairs(indices) do
78 table.insert(ranking, { id = index, poem = make_poem(index), similarity = 0.5 })
79 end
80 return ranking
81end
82-- }}}
83
84-- {{{ local function make_mapping()
85-- Builds the poem_index -> {page_number, total_pages, ...} table exactly as
86-- compute_chronological_mapping does, from a plain index-to-page list. Written
87-- by hand rather than computed so the expected page numbers are visible in the
88-- test itself, not derived by the same arithmetic under test.
89local function make_mapping(pages_by_index, total_pages)
90 local mapping = {}
91 for index, page in pairs(pages_by_index) do
92 mapping[index] = {
93 position = (page - 1) * 88 + 1,
94 page_number = page,
95 total_poems = total_pages * 88,
96 total_pages = total_pages,
97 timeline_progress = 50,
98 }
99 end
100 return mapping
101end
102-- }}}
103
104-- {{{ local function chrono_targets()
105-- Extracts every chronological link from generated HTML as
106-- {["poem-7320"] = "83"} so a case can assert one poem's page without caring
107-- about the order poems were rendered in. "index" is recorded as the page for
108-- the unpaginated / fallback target.
109local function chrono_targets(html)
110 local targets = {}
111 for page, anchor in html:gmatch("chronological/([a-z0-9]+)%.html#(poem%-%d+)") do
112 targets[anchor] = page
113 end
114 return targets
115end
116-- }}}
117
118-- {{{ local function read_file()
119local function read_file(path)
120 local handle = io.open(path, "r")
121 if not handle then return nil end
122 local contents = handle:read("*a")
123 handle:close()
124 return contents
125end
126-- }}}
127
128-- The corpus these cases pretend to be walking: 5 poems scattered across a
129-- 90-page chronological view, deliberately NOT on page 1, because page 1 is the
130-- value the old fallback produced and would otherwise look like a pass.
131local PAGES_BY_INDEX = { [7] = 7, [120] = 2, [3549] = 41, [7320] = 83, [96] = 90 }
132local TOTAL_PAGES = 90
133local ANCHOR_INDEX = 7
134local NEIGHBOUR_INDICES = { 120, 3549, 7320, 96 }
135
136-- {{{ Case: paginated build aims every link at its own page
137-- The regression case. Each poem must land on its own chronological page, and
138-- the anchor poem is included because it is rendered by a separate call inside
139-- the formatter and could drop the mapping independently of its neighbours.
140do
141 local mapping = make_mapping(PAGES_BY_INDEX, TOTAL_PAGES)
142 local html = generator.generate_flat_poem_list_html(
143 make_poem(ANCHOR_INDEX), make_ranking(NEIGHBOUR_INDICES), "similar", ANCHOR_INDEX,
144 mapping, true)
145 local targets = chrono_targets(html)
146
147 check("anchor poem links to its own page", targets["poem-7"] == "07",
148 "got " .. tostring(targets["poem-7"]) .. ", wanted 07")
149 check("neighbour on page 2 links to 02", targets["poem-120"] == "02",
150 "got " .. tostring(targets["poem-120"]))
151 check("neighbour on page 41 links to 41", targets["poem-3549"] == "41",
152 "got " .. tostring(targets["poem-3549"]))
153 check("neighbour on page 83 links to 83", targets["poem-7320"] == "83",
154 "got " .. tostring(targets["poem-7320"]))
155 check("neighbour on page 90 links to 90", targets["poem-96"] == "90",
156 "got " .. tostring(targets["poem-96"]))
157
158 -- The shape of the failure being guarded against: everything collapsing to
159 -- one page. Stated separately so a regression reads as "all links collapsed"
160 -- rather than four unrelated failures.
161 local distinct = {}
162 local count = 0
163 for _, page in pairs(targets) do
164 if not distinct[page] then distinct[page] = true; count = count + 1 end
165 end
166 check("links are spread across pages, not collapsed onto one", count == 5,
167 "found " .. count .. " distinct target pages, wanted 5")
168end
169-- }}}
170
171-- {{{ Case: page numbers are zero-padded to match the written filenames
172-- The chronological writer emits %02d names (01.html, 07.html). A link of
173-- "7.html" would 404 even though the page number is right.
174do
175 local mapping = make_mapping({ [7] = 7 }, TOTAL_PAGES)
176 local html = generator.generate_flat_poem_list_html(
177 make_poem(7), make_ranking({}), "similar", 7, mapping, true)
178 check("single-digit page is zero-padded", html:find("chronological/07%.html") ~= nil,
179 "no 07.html link found")
180 check("unpadded page name is never emitted", html:find("chronological/7%.html") == nil)
181end
182-- }}}
183
184-- {{{ Case: unpaginated build targets index.html
185-- When the chronological view is NOT split into pages, the only file written is
186-- chronological/index.html. Linking to 01.html there names a file that does not
187-- exist, so the link must follow the writer's branch, not assume pagination.
188do
189 local mapping = make_mapping(PAGES_BY_INDEX, 1)
190 local html = generator.generate_flat_poem_list_html(
191 make_poem(ANCHOR_INDEX), make_ranking(NEIGHBOUR_INDICES), "similar", ANCHOR_INDEX,
192 mapping, false)
193 local targets = chrono_targets(html)
194 check("unpaginated build links to index.html", targets["poem-3549"] == "index",
195 "got " .. tostring(targets["poem-3549"]))
196 check("unpaginated build emits no numbered page", html:find("chronological/%d+%.html") == nil)
197end
198-- }}}
199
200-- {{{ Case: a missing mapping never guesses a page number
201-- The old behaviour. With no mapping the generator cannot know the page, so it
202-- must fall back to the one file that exists in both modes (index.html, which
203-- is a redirect when paginated) rather than assert "01" and be wrong for
204-- roughly 99% of a real corpus.
205do
206 local html = generator.generate_flat_poem_list_html(
207 make_poem(ANCHOR_INDEX), make_ranking(NEIGHBOUR_INDICES), "similar", ANCHOR_INDEX,
208 nil, true)
209 check("missing mapping does not guess page 01",
210 html:find("chronological/01%.html") == nil,
211 "the '01' guess is back")
212 check("missing mapping falls back to index.html",
213 html:find("chronological/index%.html") ~= nil)
214end
215-- }}}
216
217-- {{{ Case: a poem absent from the mapping falls back rather than guessing
218do
219 local mapping = make_mapping({ [7] = 7 }, TOTAL_PAGES)
220 local html = generator.generate_flat_poem_list_html(
221 make_poem(7), make_ranking({ 3549 }), "similar", 7, mapping, true)
222 local targets = chrono_targets(html)
223 check("mapped poem still resolves", targets["poem-7"] == "07")
224 check("unmapped poem falls back to index.html", targets["poem-3549"] == "index",
225 "got " .. tostring(targets["poem-3549"]))
226end
227-- }}}
228
229-- {{{ Case: the mapping survives the paginated page writer
230-- This is the function whose signature was missing the mapping parameter for a
231-- full release, so the chain is exercised all the way to a file on disk rather
232-- than stopping at the HTML-string call above.
233do
234 local mapping = make_mapping(PAGES_BY_INDEX, TOTAL_PAGES)
235 local result = generator.generate_all_paginated_pages_for_poem(
236 make_poem(ANCHOR_INDEX), make_ranking(NEIGHBOUR_INDICES), "similar", ANCHOR_INDEX,
237 SCRATCH, { 1 }, mapping, true)
238
239 check("page writer reported a generated file",
240 result ~= nil and result.files_generated ~= nil and #result.files_generated > 0)
241
242 local written = result and result.files_generated and result.files_generated[1]
243 local html = written and read_file(written)
244 check("generated page is readable", html ~= nil, "path: " .. tostring(written))
245
246 if html then
247 local targets = chrono_targets(html)
248 check("written page aims at page 83", targets["poem-7320"] == "83",
249 "got " .. tostring(targets["poem-7320"]))
250 check("written page does not collapse onto page 01",
251 targets["poem-3549"] == "41",
252 "got " .. tostring(targets["poem-3549"]))
253 end
254end
255-- }}}
256
257print(string.format("\n%d passed, %d failed", passed, failed))
258os.exit(failed == 0 and 0 or 1)
259