diff --git a/pyproject.toml b/pyproject.toml index d3738ed..5e94158 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,13 +21,14 @@ doc_build = { path = ".", editable = true } [tool.pixi.tasks] build = "python tests/build_scripts/build_docs.py build" build-diff = "python tests/build_scripts/build_diff.py" +feature-test = "python -m unittest discover -s tests/feature -t ." [tool.pixi.tasks.test] - # Convenience command to run both build and build-diff - # ie, `pixi run test` = `pixi run build ; pixi run build-diff` + # Convenience command to run feature-test, build, and build-diff + # ie, `pixi run test` = `pixi run feature-test ; pixi run build ; pixi run build-diff` # Note that `test` / depends-on tasks cannot be passed extra arguments... # (see note below) - depends-on = ["build", "build-diff"] + depends-on = ["feature-test", "build", "build-diff"] # NOTE: this cmd is just to cause an error message to print if you try to pass args # to `pixi run test` @@ -36,7 +37,7 @@ build-diff = "python tests/build_scripts/build_diff.py" # pixi run test --keep-pdf-latexn # ...will seem to work, but actually the flag is just silently discarded. # This command causes the test task to fail in this scenario - cmd = "python -c 'import sys; sys.argv[1:] and sys.exit(\"test does not accept extra args; pass them directly to build or build-diff\")'" + cmd = "python -c 'import sys; sys.argv[1:] and sys.exit(\"test does not accept extra args; pass them directly to build, build-diff, or feature-test\")'" [tool.pixi.workspace] channels = [ diff --git a/tests/_ast_diff_helpers.py b/tests/_ast_diff_helpers.py new file mode 100644 index 0000000..8c70d98 --- /dev/null +++ b/tests/_ast_diff_helpers.py @@ -0,0 +1,55 @@ +"""Shared Pandoc AST helpers for ast_diff tests. + +Factored out for tests/feature/test_ast_diff.py and a soon-to-come +tests/regression/test_diff_ordering_with_duplicate_nodes.py. The functions +construct just enough of the real Pandoc AST shape for the ast_diff module +to walk over without going through pandoc itself. +""" + + +def _str(s): + return {"t": "Str", "c": s} + + +def _space(): + return {"t": "Space"} + + +def _inlines(text): + """Pandoc inline list from a plain string (single-space-separated words).""" + out = [] + for i, word in enumerate(text.split()): + if i: + out.append(_space()) + out.append(_str(word)) + return out + + +def header(level, ident, text): + return {"t": "Header", "c": [level, [ident, [], []], _inlines(text)]} + + +def para(text): + return {"t": "Para", "c": _inlines(text)} + + +def plain(text): + return {"t": "Plain", "c": _inlines(text)} + + +def bullet_list(*item_texts): + return {"t": "BulletList", "c": [[plain(t)] for t in item_texts]} + + +def diff_classes(node): + if node.get("t") == "Div": + return tuple(node["c"][0][1]) + return () + + +def block_kind(node): + """Short identifier for asserting on diff structure.""" + cls = diff_classes(node) + if cls: + return f"Div({'+'.join(cls)})" + return node.get("t", "?") diff --git a/tests/feature/README.md b/tests/feature/README.md new file mode 100644 index 0000000..2e6bef6 --- /dev/null +++ b/tests/feature/README.md @@ -0,0 +1,24 @@ +# Feature tests for `doc_build` + +Stdlib-`unittest` tests that exercise the public behavior of `doc_build` +modules end-to-end (within Python — they do not invoke pandoc). No +third-party test dependency. + +These are *not* strict unit tests — they call high-level entry points +(e.g. `diff_block_lists`) and let the call fan out through helpers like +`find_longest_common_subsequence`, `_pair_adjacent_changes`, and +`diff_list_nodes` rather than mocking them. + +Tests that *specifically* reproduce a previously fixed bug live under +[`tests/regression/`](../regression/) instead — those tests should be +expected to fail against the unfixed code. + +## Running + +From the repo root: + + pixi run python -m unittest discover -s tests/feature -t . + +Or run a single file directly: + + pixi run python tests/feature/test_ast_diff.py diff --git a/tests/feature/__init__.py b/tests/feature/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/feature/test_ast_diff.py b/tests/feature/test_ast_diff.py new file mode 100644 index 0000000..b1e110d --- /dev/null +++ b/tests/feature/test_ast_diff.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python +"""Feature tests for doc_build.ast_diff. + +These tests construct minimal Pandoc-style block AST fragments and assert on +the structural shape of the diff output, without going through pandoc. They +exercise diff_block_lists end-to-end (no mocking of LCS / pairing helpers). +Tests that specifically reproduce a previously fixed bug live under +tests/regression/. +""" + +import sys +import unittest +from pathlib import Path + +# Make `doc_build` importable when run as a script (e.g. `python test_ast_diff.py`) +# as well as via `python -m unittest discover -s tests/feature`. +_REPO_ROOT = Path(__file__).resolve().parents[2] +_TESTS_DIR = _REPO_ROOT / "tests" +for _p in (_REPO_ROOT, _TESTS_DIR): + if str(_p) not in sys.path: + sys.path.insert(0, str(_p)) + +from doc_build.ast_diff import diff_block_lists # noqa: E402 + +from _ast_diff_helpers import ( # noqa: E402 + block_kind, + bullet_list, + diff_classes, + header, + para, +) + + +class TestDiffBlockListsBasics(unittest.TestCase): + """Sanity checks for the simple, no-duplicates path.""" + + def test_no_changes_returns_inputs_unwrapped(self): + before = [header(2, "h", "Title"), para("body text")] + after = [header(2, "h", "Title"), para("body text")] + merged = diff_block_lists(before, after) + self.assertEqual([block_kind(b) for b in merged], ["Header", "Para"]) + + def test_pure_insertion(self): + before = [header(2, "h", "Title")] + after = [header(2, "h", "Title"), para("new body")] + merged = diff_block_lists(before, after) + self.assertEqual( + [block_kind(b) for b in merged], ["Header", "Div(insertion)"] + ) + + def test_pure_deletion(self): + before = [header(2, "h", "Title"), para("old body")] + after = [header(2, "h", "Title")] + merged = diff_block_lists(before, after) + self.assertEqual( + [block_kind(b) for b in merged], ["Header", "Div(deletion)"] + ) + + def test_changed_para_becomes_substitution(self): + before = [header(2, "h", "Title"), para("old body")] + after = [header(2, "h", "Title"), para("new body")] + merged = diff_block_lists(before, after) + self.assertEqual( + [block_kind(b) for b in merged], ["Header", "Div(substitution)"] + ) + + def test_changed_bullet_list_returns_diffed_list(self): + before = [bullet_list("a", "b", "c")] + after = [bullet_list("a", "z", "c")] + merged = diff_block_lists(before, after) + self.assertEqual([block_kind(b) for b in merged], ["BulletList"]) + # The middle item should be a per-item substitution Div. + items = merged[0]["c"] + self.assertEqual(diff_classes(items[1][0]), ("substitution",)) + + +class TestDiffBlockListsStructuralShape(unittest.TestCase): + """Structural checks involving duplicates and drift. + + These cases exercise the same code paths that the LCS-ordering regression + test in tests/regression covers, but they happen to produce the same + output under both the buggy and fixed implementations, so they aren't + sufficient on their own to catch the bug. They serve as documentation + of the expected shape and as guard rails against unrelated regressions. + """ + + def test_duplicated_paragraphs_with_local_change_yields_substitution(self): + # Both 'Deprecated Fields' subsections share the SAME boilerplate + # paragraph ("These fields are reserved..."). The pandoc-style + # auto-numbered ids make the headings distinct. Only the *first* + # subsection's bullet list differs between before and after. + boiler = para("These fields are reserved though their usage is deprecated") + + before = [ + header(4, "deprecated-fields", "Deprecated Fields"), + boiler, + bullet_list("relocates", "permission"), + header(3, "property-spec", "Property Spec"), + header(4, "deprecated-fields-1", "Deprecated Fields"), + boiler, + bullet_list("foo", "bar"), + ] + after = [ + header(4, "deprecated-fields", "Deprecated Fields"), + boiler, + # added a leading item: + bullet_list("displayGroupOrder", "relocates", "permission"), + header(3, "property-spec", "Property Spec"), + header(4, "deprecated-fields-1", "Deprecated Fields"), + boiler, + bullet_list("foo", "bar"), + ] + + merged = diff_block_lists(before, after) + kinds = [block_kind(b) for b in merged] + + # Expected: heading, paragraph, (single diffed BulletList from + # diff_list_nodes), property-spec heading, second heading, paragraph, + # second (unchanged) BulletList. + self.assertEqual( + kinds, + [ + "Header", + "Para", + "BulletList", + "Header", + "Header", + "Para", + "BulletList", + ], + "boiler paragraph must remain between the heading and the diffed " + "bullet list, not be displaced by a separate insertion+deletion", + ) + + # The diffed BulletList should carry one item-level insertion. + diffed_list = merged[2] + items = diffed_list["c"] + item_kinds = [block_kind(item[0]) for item in items] + self.assertEqual( + item_kinds, + ["Div(insertion)", "Plain", "Plain"], + "first item should be an item-level insertion, others preserved", + ) + + def test_transposition_emits_separate_insertion_and_deletion(self): + # Pure transposition: 'X' moves from before slot 1 to after slot 2. + # LCS-based diffs cannot represent moves, so the canonical output is + # a separate deletion (at the old location) and insertion (at the + # new location), rather than a substitution. This is the same shape + # under both the buggy and fixed implementations - it documents that + # the fix did not change handling of transpositions. + a = header(2, "a", "A") + b = header(2, "b", "B") + x = header(2, "x", "X") + y = header(2, "y", "Y") + + before = [a, x, b, y] + after = [a, b, x, y] + + merged = diff_block_lists(before, after) + kinds = [block_kind(blk) for blk in merged] + + # The selected LCS (with the current tie-breaking) is [A, B, Y], so + # the unmatched X appears as a deletion before B and an insertion + # after B. Either is fine as long as one is a deletion, the other + # an insertion, and they straddle a preserved B. + self.assertEqual( + kinds, + [ + "Header", + "Div(deletion)", + "Header", + "Div(insertion)", + "Header", + ], + "transposition should emit a deletion+insertion pair around the " + "preserved element, not a substitution", + ) + + +if __name__ == "__main__": + unittest.main()