Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,24 @@ jobs:
test -f build-ubuntu-appimage.sh
test -f scripts/fetch_aces12.py
test -f resources/ocio/ACES_1.2_SOURCE.txt

pytest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install Qt offscreen system libraries
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-linux.txt -r requirements-dev.txt
- name: Run test suite (headless)
env:
QT_QPA_PLATFORM: offscreen
run: pytest -q
5 changes: 5 additions & 0 deletions constants/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@

DEFAULT_SKETCH_COLOR = (255, 170, 0)

# Pinned-comment markers drawn on the frame.
COMMENT_PIN_RADIUS = 10
COMMENT_PIN_COLOR = (255, 68, 68)
COMMENT_PIN_DONE_COLOR = (76, 175, 80)

OPEN_EXTENSIONS = [
"exr", "png", "jpg", "jpeg", "mp4", "mov", "avi", "fdplaylist"
]
Expand Down
289 changes: 289 additions & 0 deletions doc/PLAN-reviewapp-parity.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
addopts = -q
5 changes: 5 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Development / test dependencies (not shipped in release builds).
# Install alongside the platform requirements, e.g.:
# pip install -r requirements-windows.txt -r requirements-dev.txt
pytest>=8,<10
pypdf>=4
Empty file added tests/__init__.py
Empty file.
42 changes: 42 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Shared pytest configuration for the FrameDeck test suite.

The whole suite runs headless: we force Qt's ``offscreen`` platform plugin so
widgets can be constructed, rendered to a ``QImage`` and pixel-probed without a
display. Environment variables are set at import time -- before any PySide6 or
OpenCV import -- because Qt reads ``QT_QPA_PLATFORM`` once at ``QApplication``
construction and OpenCV reads its FFmpeg options once per ``VideoCapture``.
"""

import os
import sys
import tempfile
from pathlib import Path

# Headless Qt + single-threaded FFmpeg decode (same guard the app relies on).
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ.setdefault("OPENCV_FFMPEG_CAPTURE_OPTIONS", "threads;1")

# Keep the app's profile/cache writes inside a throwaway dir instead of the real
# user Documents folder (main.py points FRAMEDECK_PROFILE_ROOT at Documents).
os.environ.setdefault(
"FRAMEDECK_PROFILE_ROOT",
tempfile.mkdtemp(prefix="framedeck-test-"),
)

# Make the repo root importable so `import constants`, `import widgets...` work
# regardless of where pytest is invoked from.
_REPO_ROOT = Path(__file__).resolve().parent.parent
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))

import pytest


@pytest.fixture(scope="session")
def qapp():
"""A single ``QApplication`` shared by every test that touches Qt widgets."""
from PySide6.QtWidgets import QApplication

app = QApplication.instance() or QApplication([])
yield app
# Do not call app.quit(): a session-wide instance is reused across tests.
100 changes: 100 additions & 0 deletions tests/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Reusable test helpers: offscreen rendering, pixel probing, synthetic media.

These utilities let the suite produce real evidence (a rendered widget, a probed
pixel, a decodable clip) without any external asset files or a display server.
"""

from pathlib import Path

import numpy as np


# --------------------------------------------------------------------------- #
# Offscreen rendering / pixel probing
# --------------------------------------------------------------------------- #
def render_widget_to_image(widget, size=None):
"""Render *widget* to a ``QImage`` via ``QWidget.grab`` (works offscreen).

Pass ``size`` as ``(w, h)`` to force a size; otherwise the widget's current
or hinted size is used.
"""
from PySide6.QtCore import QSize

if size is not None:
widget.resize(*size)
elif widget.size().isEmpty():
hint = widget.sizeHint()
widget.resize(hint if hint.isValid() else QSize(200, 200))
return widget.grab().toImage()


def probe_pixel(image, x, y):
"""Return ``(r, g, b, a)`` (0-255) for pixel ``(x, y)`` of a ``QImage``."""
color = image.pixelColor(int(x), int(y))
return (color.red(), color.green(), color.blue(), color.alpha())


# --------------------------------------------------------------------------- #
# Synthetic media generators
# --------------------------------------------------------------------------- #
def make_solid_mp4(path, frames=16, width=128, height=72, color=(200, 40, 40), fps=24):
"""Write a solid-*color* (RGB) MP4 of *frames* length. Returns ``path``.

Uses mpeg4 for portability across PyAV builds. Colour survives yuv420p only
approximately, so probe decoded frames with a tolerance rather than exactly.
"""
import av

path = Path(path)
container = av.open(str(path), mode="w")
try:
stream = container.add_stream("mpeg4", rate=fps)
stream.width = width
stream.height = height
stream.pix_fmt = "yuv420p"

rgb = np.zeros((height, width, 3), dtype=np.uint8)
rgb[:, :] = color
for _ in range(frames):
frame = av.VideoFrame.from_ndarray(rgb, format="rgb24")
for packet in stream.encode(frame):
container.mux(packet)
for packet in stream.encode():
container.mux(packet)
finally:
container.close()
return path


def make_png_sequence(directory, frames=4, width=64, height=64, start=1, pad=4, base="frame"):
"""Write a numbered PNG sequence (``base.0001.png`` ...). Returns path list."""
import cv2

directory = Path(directory)
directory.mkdir(parents=True, exist_ok=True)
paths = []
for i in range(frames):
number = start + i
bgr = np.zeros((height, width, 3), dtype=np.uint8)
bgr[:, :] = ((i * 30) % 256, 40, 200) # BGR: mostly-red, varies per frame
out = directory / f"{base}.{number:0{pad}d}.png"
cv2.imwrite(str(out), bgr)
paths.append(out)
return paths


def make_exr(path, width=8, height=8, value=(0.5, 0.25, 0.75)):
"""Write a solid linear-*value* RGB float EXR. Returns ``path``."""
import OpenImageIO as oiio

path = Path(path)
spec = oiio.ImageSpec(width, height, 3, "float")
out = oiio.ImageOutput.create(str(path))
if out is None: # pragma: no cover - only if OIIO lacks the EXR plugin
raise RuntimeError("OpenImageIO could not create an EXR writer")
out.open(str(path), spec)
pixels = np.zeros((height, width, 3), dtype=np.float32)
pixels[:, :] = value
out.write_image(pixels)
out.close()
return path
68 changes: 68 additions & 0 deletions tests/test_annotation_redo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Unit tests for Sketch undo/redo (widgets.annotations)."""

from widgets.annotations import Sketch


def _add_created_stroke(sketch, frame, stroke_id, stroke_type="pencil"):
"""Mimic drawing a stroke: add it and record a 'create' undo action."""
sketch.strokes.setdefault(frame, []).append(
{"id": stroke_id, "type": stroke_type, "points": []}
)
sketch._record_action({"type": "create", "frame": frame, "stroke_id": stroke_id})


def _ids(sketch, frame):
return [stroke["id"] for stroke in sketch.strokes.get(frame, [])]


def test_redo_restores_created_stroke(qapp):
sketch = Sketch()
_add_created_stroke(sketch, 1, "a")
sketch.undo()
assert 1 not in sketch.strokes
sketch.redo()
assert _ids(sketch, 1) == ["a"]


def test_new_action_clears_redo_stack(qapp):
sketch = Sketch()
_add_created_stroke(sketch, 1, "a")
sketch.undo()
assert sketch.redo_history # there is something to redo
_add_created_stroke(sketch, 2, "b") # a fresh edit
assert not sketch.redo_history # ...invalidates redo
sketch.redo() # no-op
assert 1 not in sketch.strokes


def test_multiple_undo_then_redo_sequence(qapp):
sketch = Sketch()
_add_created_stroke(sketch, 1, "a")
_add_created_stroke(sketch, 1, "b")
sketch.undo() # remove b
sketch.undo() # remove a
assert 1 not in sketch.strokes
sketch.redo() # restore a
assert _ids(sketch, 1) == ["a"]
sketch.redo() # restore b
assert _ids(sketch, 1) == ["a", "b"]


def test_redo_with_empty_stack_is_noop(qapp):
sketch = Sketch()
sketch.redo() # must not raise
assert sketch.strokes == {}


def test_erase_undo_and_redo(qapp):
sketch = Sketch()
sketch.strokes[3] = [{"id": "x", "type": "pencil"}]
# Erase records the pre-erase snapshot, then clears the frame.
sketch._record_action(
{"type": "erase", "frame": 3, "strokes": [{"id": "x", "type": "pencil"}]}
)
sketch.strokes[3] = []
sketch.undo() # restores x
assert _ids(sketch, 3) == ["x"]
sketch.redo() # re-applies the erase
assert sketch.strokes.get(3) == []
Loading