Files
noteman-slicer/tests/test_editor.py
T
Esa Kataja 68e41c2470 Give the editor a visual identity and a real levels control
The panel now uses the same colours the page is drawn with, on dark
chrome so the scan is the brightest thing on screen. Levels move from two
anonymous sliders to the scan's own histogram with draggable black and
white points and a tone strip, since getting them wrong is the one
mistake that only shows up on the tablet. A page rail replaces the
stepper and carries each page's slice count; export is pinned below the
scroll instead of below the fold; marker types read as prose.
2026-07-29 15:39:24 +03:00

147 lines
5.2 KiB
Python

"""Runnable check that the editor builds and its edits reach project state.
Runs offscreen, so it verifies wiring rather than appearance: that the widgets
construct, that an edit changes the model, and that autosave and export work.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import numpy as np # noqa: E402
import pymupdf # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from PySide6.QtWidgets import QApplication # noqa: E402
from noteman_slicer import bundle # noqa: E402
from noteman_slicer.detect import detect_page # noqa: E402
from noteman_slicer.editor import Editor # noqa: E402
from noteman_slicer.pdf import open_source, page_raster # noqa: E402
from noteman_slicer.project import Cut, Project, default_path # noqa: E402
W, H = 1200, 1600
def _scan_pdf(path: Path) -> None:
art = np.full((H, W), 255, np.uint8)
for top in (300, 800):
art[top : top + 200, 100:104] = 0
for staff in (top, top + 140):
for i in range(5):
art[staff + i * 15 : staff + i * 15 + 2, 110:1100] = 0
pix = pymupdf.Pixmap(pymupdf.csGRAY, W, H, bytearray(art.tobytes()), False)
doc = pymupdf.open()
doc.new_page(width=595, height=842).insert_image(pymupdf.Rect(0, 0, 595, 842), pixmap=pix)
doc.save(path)
def main() -> int:
tmp = Path(__file__).with_name("_tmp")
tmp.mkdir(exist_ok=True)
pdf = tmp / "scan.pdf"
_scan_pdf(pdf)
app = QApplication.instance() or QApplication(sys.argv[:1])
source = open_source(pdf)
gray = page_raster(source, 0)
project = Project.from_detection(pdf, [detect_page(gray)], [gray.shape[0]])
editor = Editor(source, project)
page = project.pages[0]
# Cuts.
before = page.slice_count
editor.view.selected_cut = page.add_cut(Cut.straight(0.5))
editor.view.redraw()
assert page.slice_count == before + 1
# A vertex turns a straight cut into a stepped one.
cut = page.cuts[editor.view.selected_cut]
cut.points.insert(1, (0.4, cut.y_at(0.4)))
cut.points[1] = (0.4, cut.points[1][1] + 0.03)
assert cut.straight_y is None, "the cut should no longer be straight"
editor.view.redraw()
# Discard.
editor.view.selected_slice = 1
was = page.discards[1]
editor.view.toggle_discard()
assert page.discards[1] != was
# Skew and levels reach the model and re-render without raising.
editor.skew.setValue(-1.4)
assert abs(page.skew + 1.4) < 1e-6
# The levels bar carries the page's own histogram and drives the model
# directly — there are no sliders behind it to keep in step.
assert editor.levels.hist.sum() > 0, "the histogram should hold the scan"
editor.levels.set_levels(40, 210)
editor._levels_changed(40, 210)
assert project.page_levels(0) == (40, 210)
editor.rail.picked.emit(0) # the page rail navigates
assert editor.index == 0 and len(editor.rail.buttons) == len(project.pages)
# Metadata.
editor.metadata["title"].setText("Ketun joululaulu")
editor.metadata["composer"].setText("trad.")
assert project.metadata["title"] == "Ketun joululaulu"
# Content rectangle edits, and reset going back to detection's proposal for
# the page as it now stands — not to the whole page, which would undo the
# thing the rectangle exists for.
expected = detect_page(editor._preview(0), skew=0.0).content
page.content_rect = (0.05, 0.02, 0.95, 0.98)
assert project.page_content_rect(0) == (0.05, 0.02, 0.95, 0.98)
editor._reset_rect()
assert project.page_content_rect(0) == expected, (project.page_content_rect(0), expected)
assert project.page_content_rect(0) != (0.0, 0.0, 1.0, 1.0)
# Autosave target, then a round-trip through disk.
editor._save()
saved = default_path(pdf)
assert saved.exists()
reloaded = Project.load(saved)
assert reloaded.metadata["title"] == "Ketun joululaulu"
assert reloaded.pages[0].skew == -1.4
assert reloaded.pages[0].levels == (40, 210)
assert [c.points for c in reloaded.pages[0].cuts] == [c.points for c in page.cuts]
# The page fits the viewport once the window has a real size. show_page's
# own fit runs before layout, when the viewport is still its default.
editor.resize(900, 700)
editor.show()
app.processEvents()
scene = editor.view.sceneRect()
scale = editor.view.transform().m11()
viewport = editor.view.viewport()
fill = max(
scale * scene.width() / viewport.width(),
scale * scene.height() / viewport.height(),
)
# Fit means nearly touching one edge — Qt leaves a small margin of its own.
# A "≤ 1" check alone would pass a page zoomed down to a dot.
assert 0.9 <= fill <= 1.02, f"page is not fitted to the window: {fill:.3f}"
# The bundle is named after the song, not the PDF.
assert bundle.filename(project) == "Ketun-joululaulu.zip"
project.metadata["title"] = "AC/DC: T.N.T. (live)"
assert bundle.filename(project) == "ACDC-T.N.T.-live.zip"
project.metadata["title"] = "Ketun joululaulu"
editor.close()
source.close()
for f in (pdf, saved):
f.unlink()
tmp.rmdir()
print("ok")
return 0
if __name__ == "__main__":
sys.exit(main())