Skip to content

Commit 559d414

Browse files
committed
add tests
1 parent a33d580 commit 559d414

10 files changed

Lines changed: 293 additions & 91 deletions

File tree

.github/workflows/tests.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ jobs:
2525
run: blender -b -P tests/python.py -- -m pip install ".[test]"
2626
- name: Run Tests in Blender
2727
run: blender -b -P tests/run.py -- -vv tests --cov --cov-report=xml
28+
- name: Build and validate extension
29+
run: |
30+
mkdir -p build
31+
blender --factory-startup --command extension build --source-dir typst_importer --output-dir build
32+
blender --factory-startup --command extension validate build/*.zip
2833
2934

3035

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
* Preserve glyph holes with the current `fill_id` and `hide_stroke` drawing attributes.
99
* Add a shared Geometry Nodes modifier with an editable Grease Pencil stroke radius.
1010
* Restore black outlines while retaining each Typst color as the Grease Pencil fill.
11+
* Restore Geometry Nodes path and visibility animation tools for Blender 5.2.
12+
* Keep routine imports from purging unrelated orphaned Blender data.
13+
* Validate extension archives and core importer workflows in CI.
1114

1215
## v0.3.2
1316

build.py

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
import glob
88
import os
9+
from pathlib import Path
10+
import shlex
911
import subprocess
1012
import sys
1113
from dataclasses import dataclass
@@ -14,9 +16,11 @@
1416

1517

1618

19+
ROOT_DIR = Path(__file__).resolve().parent
1720
ADDON_NAME = "typst_importer"
18-
TOML_PATH = f"./{ADDON_NAME}/blender_manifest.toml"
19-
WHL_PATH = f"./{ADDON_NAME}/wheels"
21+
ADDON_DIR = ROOT_DIR / ADDON_NAME
22+
TOML_PATH = ADDON_DIR / "blender_manifest.toml"
23+
WHL_PATH = ADDON_DIR / "wheels"
2024

2125
# Instead of reading from pyproject.toml, define the required packages here:
2226
required_packages = ["typst", "databpy" ,"svg.path" , "lxml"]
@@ -25,7 +29,7 @@ def run_python(args: str | List[str]):
2529
python = os.path.realpath(sys.executable)
2630

2731
if isinstance(args, str):
28-
args = [python] + args.split(" ")
32+
args = [python, *shlex.split(args)]
2933
elif isinstance(args, list):
3034
args = [python] + args
3135
else:
@@ -34,7 +38,7 @@ def run_python(args: str | List[str]):
3438
"or a list of individual arguments already split"
3539
)
3640

37-
subprocess.run(args)
41+
subprocess.run(args, check=True)
3842

3943

4044
try:
@@ -152,16 +156,20 @@ def build_extension(split: bool = True) -> None:
152156
for suffix in [".blend1", ".MNSession"]:
153157
clean_files(suffix=suffix)
154158

159+
command = [
160+
bpy.app.binary_path,
161+
"--factory-startup",
162+
"--command",
163+
"extension",
164+
"build",
165+
"--source-dir",
166+
str(ADDON_DIR),
167+
"--output-dir",
168+
str(ROOT_DIR),
169+
]
155170
if split:
156-
subprocess.run(
157-
f"{bpy.app.binary_path} --command extension build"
158-
f" --split-platforms --source-dir {ADDON_NAME} --output-dir ".split(" ")
159-
)
160-
else:
161-
subprocess.run(
162-
f"{bpy.app.binary_path} --command extension build "
163-
f"--source-dir {ADDON_NAME} --output-dir .".split(" ")
164-
)
171+
command.append("--split-platforms")
172+
subprocess.run(command, check=True)
165173

166174

167175
def build(platform) -> None:
@@ -177,4 +185,4 @@ def main():
177185

178186

179187
if __name__ == "__main__":
180-
main()
188+
main()
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""Blender 5.2 coverage for import and Geometry Nodes utility workflows."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
import bpy
8+
import pytest
9+
10+
import typst_importer
11+
from typst_importer.node_groups import (
12+
create_follow_curve_node_group,
13+
modifier_input,
14+
)
15+
from typst_importer.operators.path import configure_follow_path_animation
16+
from typst_importer.operators.textbox_import import ImportFromTextboxAsCurveOperator
17+
from typst_importer.operators.visibility import toggle_visibility
18+
from typst_importer.typst_to_svg import deduplicate_materials, typst_express
19+
20+
21+
pytestmark = pytest.mark.skipif(
22+
bpy.app.version < (5, 2, 0),
23+
reason="These workflows require Blender 5.2 or newer",
24+
)
25+
26+
27+
@pytest.fixture(autouse=True)
28+
def clean_blender_data():
29+
bpy.ops.wm.read_factory_settings(use_empty=True)
30+
bpy.context.scene.frame_set(1)
31+
yield
32+
bpy.ops.wm.read_factory_settings(use_empty=True)
33+
34+
35+
def _fcurve_frames(obj: bpy.types.Object, data_path: str) -> list[float]:
36+
action = obj.animation_data.action
37+
slot = obj.animation_data.action_slot
38+
fcurve = next(
39+
curve
40+
for layer in action.layers
41+
for strip in layer.strips
42+
for curve in strip.channelbag(slot).fcurves
43+
if curve.data_path == data_path
44+
)
45+
return [point.co.x for point in fcurve.keyframe_points]
46+
47+
48+
def test_visibility_uses_typed_modifier_input_and_keyframes():
49+
mesh = bpy.data.meshes.new("VisibilityMesh")
50+
obj = bpy.data.objects.new("VisibilityObject", mesh)
51+
bpy.context.scene.collection.objects.link(obj)
52+
53+
modifier = toggle_visibility(obj, current_frame=5, make_visible=True)
54+
input_value, socket = modifier_input(modifier, "Visibility")
55+
56+
assert input_value.type == "VALUE"
57+
assert input_value.value is False
58+
data_path = f'modifiers["{modifier.name}"].properties.inputs.{socket.identifier}.value'
59+
assert _fcurve_frames(obj, data_path) == [4.0, 5.0]
60+
61+
62+
def test_follow_path_uses_typed_object_and_factor_inputs():
63+
mesh = bpy.data.meshes.new("FollowerMesh")
64+
follower = bpy.data.objects.new("Follower", mesh)
65+
bpy.context.scene.collection.objects.link(follower)
66+
67+
curve_data = bpy.data.curves.new("Path", type="CURVE")
68+
path = bpy.data.objects.new("Path", curve_data)
69+
bpy.context.scene.collection.objects.link(path)
70+
spline = curve_data.splines.new("POLY")
71+
spline.points.add(1)
72+
spline.points[0].co = (0.0, 0.0, 0.0, 1.0)
73+
spline.points[1].co = (2.0, 0.0, 0.0, 1.0)
74+
75+
modifier = follower.modifiers.new("FollowPath", type="NODES")
76+
modifier.node_group = create_follow_curve_node_group()
77+
configure_follow_path_animation(follower, modifier, path, current_frame=3)
78+
79+
object_input, _ = modifier_input(modifier, "Object")
80+
factor_input, factor_socket = modifier_input(modifier, "Factor")
81+
assert object_input.type == "VALUE"
82+
assert object_input.value == path
83+
assert factor_input.type == "VALUE"
84+
assert factor_input.value == pytest.approx(0.0)
85+
factor_path = (
86+
f'modifiers["{modifier.name}"].properties.inputs.'
87+
f"{factor_socket.identifier}.value"
88+
)
89+
assert _fcurve_frames(follower, factor_path) == [3.0, 13.0]
90+
91+
configure_follow_path_animation(follower, modifier, path, current_frame=8)
92+
assert _fcurve_frames(follower, factor_path) == [8.0, 18.0]
93+
94+
95+
@pytest.mark.parametrize(
96+
("kwargs", "object_type", "fill_mode"),
97+
[
98+
({"convert_to_mesh": False}, "CURVE", "BOTH"),
99+
({}, "MESH", None),
100+
(
101+
{"convert_to_mesh": False, "convert_to_unfilled_path": True},
102+
"CURVE",
103+
"NONE",
104+
),
105+
],
106+
)
107+
def test_typst_import_modes(kwargs, object_type, fill_mode):
108+
collection = typst_express(
109+
'#rect(width: 12pt, height: 12pt, fill: rgb("#336699"))',
110+
name=f"pytest_{object_type}_{fill_mode}",
111+
**kwargs,
112+
)
113+
114+
objects = list(collection.objects)
115+
assert objects
116+
assert {obj.type for obj in objects} == {object_type}
117+
if fill_mode is not None:
118+
assert all(obj.data.fill_mode == fill_mode for obj in objects)
119+
120+
121+
def test_material_deduplication_preserves_unrelated_orphan_data():
122+
unrelated_material = bpy.data.materials.new("KeepThisMaterial")
123+
collection = bpy.data.collections.new("MaterialDedup")
124+
bpy.context.scene.collection.children.link(collection)
125+
126+
curve = bpy.data.curves.new("ImportedCurve", type="CURVE")
127+
material = bpy.data.materials.new("ImportedMaterial")
128+
curve.materials.append(material)
129+
collection.objects.link(bpy.data.objects.new("ImportedCurve", curve))
130+
131+
deduplicate_materials(collection)
132+
133+
assert bpy.data.materials.get(unrelated_material.name) == unrelated_material
134+
135+
136+
def test_textbox_curve_importer_smoke_test(tmp_path: Path):
137+
typst_file = tmp_path / "textbox_input.typ"
138+
typst_file.write_text('#rect(width: 12pt, height: 12pt, fill: rgb("#336699"))')
139+
140+
collection = ImportFromTextboxAsCurveOperator.import_typst(
141+
None,
142+
typst_file,
143+
)
144+
145+
assert collection.objects
146+
assert {obj.type for obj in collection.objects} == {"CURVE"}
147+
148+
149+
def test_addon_registers_and_unregisters_cleanly():
150+
typst_importer.register()
151+
try:
152+
assert hasattr(bpy.ops.import_scene, "import_textbox_grease_pencil")
153+
assert hasattr(bpy.ops.export_scene, "typst_svg")
154+
finally:
155+
typst_importer.unregister()

typst_importer/node_groups.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,30 @@ def _interface_input(node_group, name):
2525
)
2626

2727

28+
def modifier_input(modifier, socket_name):
29+
"""Return a Geometry Nodes modifier input and its interface socket."""
30+
socket = _interface_input(modifier.node_group, socket_name)
31+
if socket is None:
32+
raise ValueError(
33+
f"Node group {modifier.node_group.name!r} has no {socket_name!r} input"
34+
)
35+
return getattr(modifier.properties.inputs, socket.identifier), socket
36+
37+
38+
def set_modifier_input_value(modifier, socket_name, value):
39+
"""Set a Geometry Nodes modifier input with Blender 5.2 typed RNA."""
40+
input_value, socket = modifier_input(modifier, socket_name)
41+
input_value.type = "VALUE"
42+
input_value.value = value
43+
return socket
44+
45+
46+
def modifier_input_data_path(modifier, socket_name):
47+
"""Return the Blender RNA path used to keyframe a modifier input."""
48+
_, socket = modifier_input(modifier, socket_name)
49+
return f'modifiers["{modifier.name}"].properties.inputs.{socket.identifier}.value'
50+
51+
2852
def create_grease_pencil_stroke_radius_node_group():
2953
"""Create the shared Blender 5.2 node group for Typst GP outlines.
3054
@@ -136,7 +160,6 @@ def add_grease_pencil_stroke_radius_modifier(
136160
raise ValueError("Grease Pencil stroke radius must be non-negative")
137161

138162
node_group = create_grease_pencil_stroke_radius_node_group()
139-
radius_socket = _interface_input(node_group, "Stroke Radius")
140163
modifier = obj.modifiers.new(
141164
name=GREASE_PENCIL_STROKE_NODE_GROUP,
142165
type="NODES",
@@ -146,12 +169,7 @@ def add_grease_pencil_stroke_radius_modifier(
146169
# Blender 5.2 exposes modifier inputs through a typed RNA interface. The
147170
# legacy modifier["Socket_2"] ID-property access is unsupported by the
148171
# current API.
149-
modifier_input = getattr(
150-
modifier.properties.inputs,
151-
radius_socket.identifier,
152-
)
153-
modifier_input.type = "VALUE"
154-
modifier_input.value = float(stroke_radius)
172+
set_modifier_input_value(modifier, "Stroke Radius", float(stroke_radius))
155173
return modifier
156174

157175

typst_importer/operators/op_utils.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,24 @@ def get_or_create_collection(name):
1111
# Link it to the scene
1212
bpy.context.scene.collection.children.link(collection)
1313
return collection
14+
15+
16+
def animation_fcurves(obj: bpy.types.Object):
17+
"""Return the object's active Action f-curves across Blender action APIs."""
18+
if obj.animation_data is None or obj.animation_data.action is None:
19+
return ()
20+
21+
action = obj.animation_data.action
22+
if hasattr(action, "fcurves"):
23+
return action.fcurves
24+
25+
slot = obj.animation_data.action_slot
26+
if slot is None:
27+
return ()
28+
29+
for layer in action.layers:
30+
for strip in layer.strips:
31+
channelbag = strip.channelbag(slot)
32+
if channelbag is not None:
33+
return channelbag.fcurves
34+
return ()

0 commit comments

Comments
 (0)