-- Deterministic regression tests for the numerical autonode backend. -- -- Run with texlua from the repository root. These tests deliberately avoid -- PGFPlots and CoolProp: they exercise candidate preparation and assignment as -- a standalone Lua component, while the document tests cover the TeX bridge. local autonode = dofile("pgfplots-autonode.lua") local function assert_equal(actual, expected, message) if actual ~= expected then error(string.format("%s: expected %s, got %s", message, tostring(expected), tostring(actual)), 2) end end local function assert_close(actual, expected, tolerance, message) if math.abs(actual - expected) > tolerance then error(string.format("%s: expected %.8g, got %.8g", message, expected, actual), 2) end end local function add_two_choice_label(id, preferred, priority) autonode.add_label( id, 20, 8, preferred, 0, 1, false, false, 8, 1000, priority ) autonode.add_candidate(id, 0.25, 25, 50) autonode.add_candidate(id, 0.75, 75, 50) end local function assert_collision_free_algorithm(algorithm) autonode.configure({ algorithm = algorithm, bbox_mode = "axis-aligned", failure_mode = "error", border_margin = 2, overlap_tolerance = 0.2, max_iterations = 80, exact_max_labels = 10, exact_max_states = 50000, }) autonode.reset() autonode.set_axis_rect(0, 100, 0, 100) add_two_choice_label(1, 0.25, 10) add_two_choice_label(2, 0.25, 0) local labels = autonode.solve() assert_equal(#labels, 2, algorithm .. " label count") assert(labels[1].choice ~= nil, algorithm .. " must place label 1") assert(labels[2].choice ~= nil, algorithm .. " must place label 2") assert(labels[1].choice.pos ~= labels[2].choice.pos, algorithm .. " must separate colliding labels") assert(not labels[1].hidden and not labels[2].hidden, algorithm .. " must keep both feasible labels") end for _, algorithm in ipairs({"greedy", "repair", "local-search", "exact-small"}) do assert_collision_free_algorithm(algorithm) end -- When no collision-free assignment exists, the documented failure policy -- must preserve the higher-priority label and hide the lower-priority one. autonode.configure({ algorithm = "repair", bbox_mode = "oriented", failure_mode = "hide-low-priority", border_margin = 0, }) autonode.reset() autonode.set_axis_rect(0, 100, 0, 100) autonode.add_label(1, 30, 10, 0.5, 0, 1, true, false, 8, 1000, 20) autonode.add_label(2, 30, 10, 0.5, 0, 1, true, false, 8, 1000, 1) for id = 1, 2 do autonode.add_candidate(id, 0.4, 40, 50) autonode.add_candidate(id, 0.6, 60, 50) end local priority_labels = autonode.solve() assert(not priority_labels[1].hidden, "high-priority label must remain visible") assert(priority_labels[2].hidden, "low-priority label must be hidden first") -- The public overlap weight must affect the optimizer. With zero weight the -- preferred coincident candidates are retained and the failure policy hides -- the later label. With a large weight, the second label moves to its -- non-overlapping alternative and both remain visible. local function solve_weighted_overlap(weight) autonode.configure({ algorithm = "local-search", bbox_mode = "axis-aligned", failure_mode = "hide-low-priority", border_margin = 0, overlap_tolerance = 0, }) autonode.reset() autonode.set_axis_rect(0, 100, 0, 100) autonode.add_label(1, 20, 10, 0.5, 0, 0, false, false, 100, weight, 0) autonode.add_candidate(1, 0.5, 50, 50) autonode.add_label(2, 20, 10, 0.5, 0, 0, false, false, 100, weight, 0) autonode.add_candidate(2, 0.5, 50, 50) autonode.add_candidate(2, 0.9, 80, 50) return autonode.solve() end local unweighted = solve_weighted_overlap(0) assert_close(unweighted[2].choice.pos, 0.5, 1e-12, "zero overlap weight must preserve the preferred candidate") assert(unweighted[2].hidden, "the failure policy must hide the unresolved unweighted conflict") local weighted = solve_weighted_overlap(1000) assert_close(weighted[2].choice.pos, 0.9, 1e-12, "large overlap weight must select the separated candidate") assert(not weighted[1].hidden and not weighted[2].hidden, "weighted separation must keep both labels visible") -- Candidate boxes which cross the visible plot border must be rejected while -- an interior alternative remains usable. autonode.configure({ algorithm = "greedy", bbox_mode = "axis-aligned", failure_mode = "error", border_margin = 2, }) autonode.reset() autonode.set_axis_rect(0, 100, 0, 100) autonode.add_label(1, 20, 8, 0.1, 0, 0, false, false, 8, 1000, 0) autonode.add_candidate(1, 0.1, 5, 50) autonode.add_candidate(1, 0.8, 50, 50) local border_labels = autonode.solve() assert_equal(#border_labels[1].invalid_candidates, 1, "border test rejected-candidate count") assert_equal(#border_labels[1].valid_candidates, 1, "border test valid-candidate count") assert_close(border_labels[1].choice.pos, 0.8, 1e-12, "border test must select the interior candidate") -- TikZ node options can make the actual box asymmetric about its placement -- coordinate. A west anchor must be checked using its measured offsets, -- not a box centred on the path point. autonode.configure({ algorithm = "greedy", failure_mode = "error", allow_outside = false, border_margin = 0, }) autonode.reset() autonode.set_axis_rect(0, 100, 0, 100) autonode.add_label(1, 0, 0, 0.9, 0, 0, false, false, 8, 1000, 0) autonode.set_label_geometry(1, 0, 30, -5, 5) autonode.add_candidate(1, 0.9, 80, 50) autonode.add_candidate(1, 0.6, 60, 50) local anchored = autonode.solve()[1] assert_equal(#anchored.invalid_candidates, 1, "west-anchored node must reject its overflowing candidate") assert_close(anchored.choice.pos, 0.6, 1e-12, "west-anchored node must move to the interior") -- Visible-path sampling must clip the surveyed polyline before allocating -- samples. Even when two visible pieces have adjacent normalized positions, -- a PGFPlots path break must keep their component identifiers distinct. local emitted = {} tex = {sprint = function(value) emitted[#emitted + 1] = value end} autonode.reset() autonode.set_axis_rect(0, 100, 0, 100) autonode.add_label(1, 10, 5, 0.5, 0, 0, false, false, 8, 1000, 0) autonode.add_path_point(1, -100, 50, 0) autonode.add_path_point(1, 20, 50, 120) autonode.add_path_break(1) autonode.add_path_point(1, 80, 50, 0) autonode.add_path_point(1, 200, 50, 120) autonode.emit_visible_candidate_positions(1, 0.5, 0, 1, 9, "uniform") assert_equal(#emitted, 9, "visible-path candidate count") local components = {} for _, callback in ipairs(emitted) do local position, component = callback:match("samplecandidate\\endcsname{([%d%.]+)}{(%d+)}") assert(position and component, "visible-path callback syntax") position, component = tonumber(position), tonumber(component) components[component] = true assert(position >= 5 / 12 - 1e-6 and position <= 7 / 12 + 1e-6, "candidate must lie on a visible piece") end assert(components[1] and components[2], "visible disconnected pieces must retain separate components") -- Exact path endpoints use surveyed canvas vertices. PGFPlots' point-at-time -- interpolation is not reliable at 0 or 1 for some disconnected log-axis -- curves; the endpoint coordinate and normalized position must stay exact. emitted = {} autonode.reset() autonode.set_axis_rect(0, 100, 0, 100) autonode.add_label(1, 10, 5, 0.5, 0, 0, false, false, 8, 1000, 0) autonode.add_path_point(1, 10, 50, 0) autonode.add_path_point(1, 30, 50, 20) autonode.add_path_break(1) autonode.add_path_point(1, 70, 50, 0) autonode.add_path_point(1, 90, 50, 20) autonode.emit_visible_candidate_positions(1, 0.5, 0, 1, 3, "uniform") assert_equal(#emitted, 1, "only the interior needs TeX interpolation") local endpoint_candidates = autonode.solve()[1].candidates local saw_start, saw_end = false, false for _, candidate in ipairs(endpoint_candidates) do if candidate.pos == 0 and candidate.base_x == 10 then saw_start = true end if candidate.pos == 1 and candidate.base_x == 90 then saw_end = true end end assert(saw_start and saw_end, "surveyed endpoints must retain exact positions") -- A connected source polyline may also leave the viewport and later re-enter -- it. Do not bridge the hidden excursion when computing local tangents. emitted = {} autonode.reset() autonode.set_axis_rect(0, 100, 0, 100) autonode.add_label(1, 10, 5, 0.5, 0, 0, false, false, 8, 1000, 0) autonode.add_path_point(1, 10, 50, 0) autonode.add_path_point(1, 20, 50, 10) autonode.add_path_point(1, 50, 150, 104.403) autonode.add_path_point(1, 80, 50, 104.403) autonode.add_path_point(1, 90, 50, 10) autonode.emit_visible_candidate_positions(1, 0.5, 0, 1, 9, "uniform") components = {} for _, callback in ipairs(emitted) do local component = callback:match("samplecandidate\\endcsname{[%d%.]+}{(%d+)}") components[tonumber(component)] = true end assert(components[1] and components[2], "off-axis excursions must split visible candidate components") -- A wholly off-axis path emits no candidates, even under an overlap policy -- that would otherwise tolerate an imperfect placement. emitted = {} autonode.reset() autonode.set_axis_rect(0, 100, 0, 100) autonode.add_label(1, 10, 5, 0.5, 0, 0, false, false, 8, 1000, 0) autonode.add_path_point(1, -100, 50, 0) autonode.add_path_point(1, -20, 50, 80) autonode.emit_visible_candidate_positions(1, 0.5, 0, 1, 9, "uniform") assert_equal(#emitted, 0, "off-axis path must emit no samples") -- A deliberately fixed path fraction remains admissible when it falls on a -- visible segment; the zero-width permitted range must not be discarded. emitted = {} autonode.reset() autonode.set_axis_rect(0, 100, 0, 100) autonode.add_label(1, 10, 5, 0.25, 0, 0, false, false, 8, 1000, 0) autonode.add_path_point(1, 10, 50, 0) autonode.add_path_point(1, 90, 50, 80) autonode.emit_visible_candidate_positions(1, 0.25, 0.25, 0.25, 9, "uniform") assert_equal(#emitted, 1, "fixed visible position must emit exactly one sample") -- Visibility is a hard constraint even when an overlap failure policy permits -- imperfect assignments. An off-axis curve must never produce a visible -- fallback label, and it must not prevent another label from being placed. for _, algorithm in ipairs({"greedy", "repair", "local-search", "exact-small"}) do for _, failure_mode in ipairs({ "warn", "hide-low-priority", "allow-minimal-overlap", }) do autonode.configure({ algorithm = algorithm, failure_mode = failure_mode, allow_outside = false, border_margin = 2, }) autonode.reset() autonode.set_axis_rect(0, 100, 0, 100) autonode.add_label(1, 20, 8, 0.5, 0, 0, false, false, 8, 1000, 0) autonode.add_candidate(1, 0.5, -20, 50) autonode.add_label(2, 20, 8, 0.5, 0, 0, false, false, 8, 1000, 0) autonode.add_candidate(2, 0.5, 50, 50) local result = autonode.solve() assert(result[1].hidden and result[1].choice == nil, algorithm .. "/" .. failure_mode .. " must hide the outside label") assert(not result[2].hidden and result[2].choice ~= nil, algorithm .. "/" .. failure_mode .. " must keep the inside label") end end autonode.configure({ algorithm = "repair", failure_mode = "error", allow_outside = false, }) autonode.reset() autonode.set_axis_rect(0, 100, 0, 100) autonode.add_label(1, 20, 8, 0.5, 0, 0, false, false, 8, 1000, 0) autonode.add_candidate(1, 0.5, -20, 50) local ok, err = pcall(autonode.solve) assert(not ok and tostring(err):find("no valid candidate", 1, true), "error mode must reject a label with no inside candidate") -- Explicit opt-out remains available to authors who intentionally want labels -- outside the plot rectangle. autonode.configure({ algorithm = "greedy", failure_mode = "error", allow_outside = true, }) autonode.reset() autonode.set_axis_rect(0, 100, 0, 100) autonode.add_label(1, 20, 8, 0.5, 0, 0, false, false, 8, 1000, 0) autonode.add_candidate(1, 0.5, -20, 50) local opted_out = autonode.solve()[1] assert(not opted_out.hidden and opted_out.choice ~= nil, "allow_outside must explicitly permit an outside label") -- Missing plot geometry must fail closed instead of silently disabling the -- border check. autonode.configure({allow_outside = false}) autonode.reset() autonode.add_label(1, 20, 8, 0.5, 0, 0, false, false, 8, 1000, 0) autonode.add_candidate(1, 0.5, 50, 50) ok, err = pcall(autonode.solve) assert(not ok and tostring(err):find("rectangle is unavailable", 1, true), "missing axis rectangle must not allow unconstrained placement") -- Sloped labels use canvas-space tangents and are turned upright unless the -- caller explicitly permits upside-down text. local function solve_reversed_tangent(allow_upside_down) autonode.configure({ algorithm = "greedy", bbox_mode = "oriented", failure_mode = "error", border_margin = 0, allow_outside = true, }) autonode.reset() autonode.set_axis_rect(0, 100, 0, 100) autonode.add_label(1, 12, 6, 0.25, -6, 0, true, allow_upside_down, 8, 1000, 0) autonode.add_candidate(1, 0.25, 80, 50) autonode.add_candidate(1, 0.75, 20, 50) return autonode.solve()[1] end local upright = solve_reversed_tangent(false) assert_close(upright.choice.angle, 0, 1e-12, "reversed tangent must be normalized upright") assert_close(upright.choice.y, 44, 1e-12, "negative normal shift must select the opposite side") local inverted = solve_reversed_tangent(true) assert_close(math.abs(inverted.choice.angle), 180, 1e-12, "allow-upside-down must preserve the reversed tangent") -- Two parallel thin labels can have overlapping axis-aligned bounding boxes -- while their oriented rectangles remain disjoint. Exercise both collision -- models with exactly the same candidates. local function solve_diagonal_pair(mode) autonode.configure({ algorithm = "greedy", bbox_mode = mode, failure_mode = "hide-low-priority", border_margin = 0, allow_outside = true, }) autonode.reset() autonode.set_axis_rect(0, 100, 0, 100) autonode.add_label(1, 40, 4, 0.4, 0, 0, true, false, 8, 1000, 0) autonode.add_candidate(1, 0.4, 39, 39) autonode.add_candidate(1, 0.6, 41, 41) autonode.add_label(2, 40, 4, 0.4, 0, 0, true, false, 8, 1000, 0) autonode.add_candidate(2, 0.4, 49, 29) autonode.add_candidate(2, 0.6, 51, 31) return autonode.solve() end local axis_aligned = solve_diagonal_pair("axis-aligned") assert(axis_aligned[2].hidden, "axis-aligned boxes must detect the conservative diagonal conflict") local oriented = solve_diagonal_pair("oriented") assert(not oriented[1].hidden and not oriented[2].hidden, "oriented boxes must preserve disjoint diagonal labels") -- Reset is the axis-isolation boundary: no labels from an earlier axis may -- survive into the next solve. autonode.reset() autonode.set_axis_rect(0, 100, 0, 100) autonode.add_label(1, 10, 5, 0.5, 0, 0, false, false, 8, 1000, 0) autonode.add_candidate(1, 0.5, 50, 50) autonode.reset() autonode.set_axis_rect(0, 100, 0, 100) assert_equal(#autonode.solve(), 0, "reset must isolate consecutive axes") io.write("pgfplots-autonode Lua regression tests passed\n")