Files
LandPPT/tests/test_slide_html_validation.py
sligter d52b969323 fix: correct failure handling and validation across generation pipelines
Audit of the outline- and slide-generation paths found four systemic defects.
All are fixed here, with 244 regression tests added across 9 new test files.

Failures were being reported as successes. Every fallback path persisted
fabricated or broken content, marked the stage completed, billed the user and
returned a success message:
- Add OutlineRepairFailedError; the repair loop now raises instead of returning
  an outline that failed all 10 attempts.
- Drop the 3-page placeholder outline that replaced a failed generation.
- Track failed slides separately so they are not billed, not marked completed,
  and not skipped as "already generated" on a re-run.
- Stop decoding binary uploads as latin-1, which turned PDFs into mojibake
  outlines that were reported as successful.

Cancellation, reset and slide locks did nothing:
- Forward _is_slides_generation_cancelled through EnhancedPPTService, which has
  no __getattr__, so the stop button was a silent no-op for the whole run.
- Catch CancelledError when awaiting the cancelled lock renewer; it is a
  BaseException, so the distributed lock was never released.
- Add clear_project_outline/clear_project_slides; save_project_outline(None)
  and save_project_slides("", []) were both no-ops, so stage reset kept the
  old data and replayed it as a fresh result.
- Persist slide locks in slide_metadata and honour them in batch regeneration.

HTML validation was inverted. libxml2 only knows HTML 4.0, so strict parsing
rejected <header>/<section>/<svg> while accepting truncated documents:
- Add html_structure.py, an HTML5-aware structural check built on html.parser.
- Re-validate after auto-repair rather than treating "the string changed" as
  success, which made the retry budget unreachable.
- Prefer the last complete document when extracting HTML, so repair responses
  that echo the original no longer return the unrepaired input.

The streaming think-tag filter lost or leaked content when a marker was split
across chunks; rewrite it to buffer partial markers.

Also fixed:
- Confine client-supplied file paths to the upload roots (arbitrary file read).
- Escape model-generated content before it reaches innerHTML (stored XSS).
- Enforce ownership on the slide cancel endpoints and validate stage status.
- Give the fixed page-count mode a real branch; it behaved as ai_decide and
  overwrote the user's setting.
- Stop citation markers such as "[1]" from hijacking outline JSON extraction.
- Bill only slides that were actually persisted, and price the streaming and
  non-streaming outline routes identically.
- Cancel orphaned generation tasks when an SSE client disconnects.
- Release stale "running" tasks found in the database, not just in Valkey.
- Replace a 3s unconditional poll (which updated absent DOM nodes) with a
  stoppable 15s poll, and add an AbortController to prevent concurrent streams.
- Implement restore_project_version, which raised AttributeError on every call.
- Declare lxml, which was imported directly but only present transitively.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 23:01:15 +08:00

255 lines
9.8 KiB
Python

"""Regression tests for slide HTML validation, extraction and auto-repair.
The pre-fix validator used lxml's strict (HTML 4.0) parser, which rejected modern
HTML5 slides while accepting truncated documents, and the "auto fix" step declared
success whenever the output string differed from the input.
"""
import ast
import logging
from pathlib import Path
import pytest
from landppt.services.slide.html_structure import (
analyze_html_structure,
describe_structure_errors,
)
from landppt.services.slide.slide_html_cleanup_service import SlideHtmlCleanupService
REPO = Path(__file__).resolve().parents[1]
SRC = REPO / "src" / "landppt"
VALID_HTML5_SLIDE = """<!DOCTYPE html>
<html><head><meta charset="utf-8"><style>#wrap{display:flex}</style></head>
<body>
<header><h1>Title</h1></header>
<main><section>
<ul><li>first<li>second</ul>
<svg viewBox="0 0 10 10"><circle cx="5" cy="5" r="4"/></svg>
<canvas id="chart"></canvas>
<figure><img src="a.png"><figcaption>caption</figcaption></figure>
</section></main>
<footer><p>note<p>second note</footer>
</body></html>"""
class TestHtml5TagsAreAccepted:
@pytest.mark.parametrize(
"tag",
["header", "main", "section", "nav", "article", "aside", "figure", "footer"],
)
def test_semantic_html5_tags_are_well_formed(self, tag):
html = f"<!DOCTYPE html><html><head></head><body><{tag}>x</{tag}></body></html>"
report = analyze_html_structure(html)
assert report.is_well_formed, describe_structure_errors(report)
def test_full_modern_slide_passes(self):
report = analyze_html_structure(VALID_HTML5_SLIDE)
assert report.is_well_formed, describe_structure_errors(report)
assert not report.is_truncated
def test_svg_and_canvas_pass(self):
html = (
'<!DOCTYPE html><html><head></head><body>'
'<svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid"></svg>'
'<canvas id="c"></canvas><video src="v.mp4"></video>'
"</body></html>"
)
report = analyze_html_structure(html)
assert report.is_well_formed, describe_structure_errors(report)
def test_script_with_comparison_operator_passes(self):
html = (
"<!DOCTYPE html><html><head><script>if(a<b){c()}</script></head>"
"<body><div>ok</div></body></html>"
)
report = analyze_html_structure(html)
assert report.is_well_formed, describe_structure_errors(report)
def test_implied_end_tags_pass(self):
html = (
"<!DOCTYPE html><html><head></head><body>"
"<table><thead><tr><th>a<th>b</thead><tbody><tr><td>1<td>2</tbody></table>"
"<ul><li>a<li>b</ul><p>x<p>y"
"</body></html>"
)
report = analyze_html_structure(html)
assert report.is_well_formed, describe_structure_errors(report)
class TestTruncationIsDetected:
def test_truncated_mid_text_is_an_error(self):
html = '<!DOCTYPE html><html><head></head><body><div class="wrap"><h1>Ti'
report = analyze_html_structure(html)
assert report.is_truncated
assert not report.is_well_formed
assert describe_structure_errors(report)
def test_truncated_mid_tag_is_an_error(self):
html = '<!DOCTYPE html><html><head></head><body><div><span>hi</span><div class="a'
report = analyze_html_structure(html)
assert report.is_truncated
assert report.trailing_fragment
def test_unclosed_div_is_an_error(self):
html = "<!DOCTYPE html><html><head></head><body><div><p>hi</p></body></html>"
report = analyze_html_structure(html)
assert "div" in report.unclosed_tags
assert not report.is_well_formed
def test_stray_end_tag_is_an_error(self):
html = "<!DOCTYPE html><html><head></head><body><div>x</div></span></body></html>"
report = analyze_html_structure(html)
assert "span" in report.stray_end_tags
assert not report.is_well_formed
class TestAutoFixIsVerified:
def test_validator_no_longer_uses_strict_lxml(self):
source = (
SRC / "services" / "slide" / "slide_html_inspection_service.py"
).read_text(encoding="utf-8")
assert "recover=False" not in source, (
"libxml2's strict HTML 4.0 parser rejects valid HTML5 slides"
)
def test_auto_fix_no_longer_reserialises_through_lxml(self):
source = (
SRC / "services" / "slide" / "slide_html_inspection_service.py"
).read_text(encoding="utf-8")
assert "pretty_print=True" not in source, (
"re-serialising lowercases viewBox and re-flows whitespace"
)
def test_recovery_revalidates_after_parser_fix(self):
source = (
SRC / "services" / "slide" / "slide_html_recovery_service.py"
).read_text(encoding="utf-8")
marker = "parser_fixed_html = self._auto_fix_html_with_parser(html_content)"
assert marker in source
body = source[source.index(marker) :][:1400]
assert "_validate_html_completeness(parser_fixed_html)" in body, (
"a changed string is not evidence of a successful repair"
)
def test_layout_repair_revalidates_before_replacing_a_slide(self):
source = (
SRC / "services" / "slide" / "layout_repair_service.py"
).read_text(encoding="utf-8")
marker = "repaired_html = self._clean_html_response(repair_content)"
assert marker in source
body = source[source.index(marker) :][:1600]
assert "_validate_html_completeness(repaired_html)" in body
class TestSeverityGate:
@staticmethod
def _load_skip_fn():
source = (
SRC / "services" / "slide" / "layout_repair_service.py"
).read_text(encoding="utf-8")
tree = ast.parse(source)
for node in ast.walk(tree):
if (
isinstance(node, ast.FunctionDef)
and node.name == "_should_skip_layout_repair"
):
node.decorator_list = []
namespace = {}
module = ast.Module(body=[node], type_ignores=[])
exec(compile(ast.fix_missing_locations(module), "<sev>", "exec"), namespace)
return namespace["_should_skip_layout_repair"]
raise AssertionError("_should_skip_layout_repair not found")
@pytest.mark.parametrize(
"report,expected_skip",
[
("- severity: low\nConsider highlighting the key metric", True),
("- severity: low\nIncrease medium-gray contrast slightly", True),
("- severity: low\nMinor kerning nit", True),
("- severity: high\nText overflows the container", False),
("- severity: medium\nSpacing is tight", False),
("- severity: low\n- severity: high\n", False),
("Overall the contrast is high and text overflows", False),
("", False),
],
)
def test_only_structured_severity_decides(self, report, expected_skip):
skip_fn = self._load_skip_fn()
assert skip_fn(report) is expected_skip
class _CleanupStub(SlideHtmlCleanupService):
def __init__(self): # bypass the facade wiring
pass
def _strip_think_tags(self, value):
return value
DOC_NEW = '<!DOCTYPE html><html><head></head><body><div class="a">NEW</div></body></html>'
DOC_OLD = '<!DOCTYPE html><html><head></head><body><div class="a">OLD</div></body></html>'
class TestHtmlExtraction:
@pytest.fixture(autouse=True)
def _quiet_logs(self):
logging.disable(logging.CRITICAL)
yield
logging.disable(logging.NOTSET)
@pytest.fixture
def cleaner(self):
return _CleanupStub()
def test_repair_echo_picks_the_repaired_document(self, cleaner):
"""Repair prompts embed the original in a ```html fence."""
raw = f"原始HTML:\n```html\n{DOC_OLD}\n```\n修复后:\n```html\n{DOC_NEW}\n```"
out = cleaner._clean_html_response(raw)
assert "NEW" in out and "OLD" not in out
def test_trailing_snippet_does_not_win_over_full_document(self, cleaner):
raw = (
f"```html\n{DOC_OLD}\n```\n改后:\n```html\n{DOC_NEW}\n```\n"
"说明:\n```html\n<div>片段</div>\n```"
)
out = cleaner._clean_html_response(raw)
assert "NEW" in out and "OLD" not in out
def test_two_plain_documents_pick_the_last(self, cleaner):
out = cleaner._clean_html_response(f"{DOC_OLD}\n\n{DOC_NEW}")
assert "NEW" in out and "OLD" not in out
def test_refusal_is_rejected(self, cleaner):
raw = "Sorry, I cannot generate this slide right now. Please try again. <br>"
assert cleaner._clean_html_response(raw) == ""
def test_error_prose_is_rejected(self, cleaner):
raw = "Error: unable to process the request at this time."
assert cleaner._clean_html_response(raw) == ""
def test_embedded_fence_does_not_truncate_the_document(self, cleaner):
raw = (
"```html\n<!DOCTYPE html><html><body><pre>```\nnested\n```</pre>"
"<div>tail</div></body></html>\n```"
)
out = cleaner._clean_html_response(raw)
assert "tail" in out, "document truncated at the embedded fence"
def test_css_id_selector_lines_survive(self, cleaner):
raw = (
"<!DOCTYPE html>\n<html>\n<head>\n<style>\n#container {\n"
"display:flex;\n}\n</style>\n</head>\n<body>\n<div>x</div>"
)
out = cleaner._clean_html_response(raw)
assert "#container" in out
assert "display:flex" in out
def test_structural_fragment_is_accepted(self, cleaner):
raw = '<div class="slide"><h1>hi</h1></div>'
assert cleaner._clean_html_response(raw) == raw
def test_plain_fenced_document(self, cleaner):
assert "NEW" in cleaner._clean_html_response(f"```html\n{DOC_NEW}\n```")