Panels aufgeraeumt (alle vier Addons) + Test-Suite instandgesetzt
Panel-Umbau
-----------
Jedes Addon hatte seine Einstellungen als Kette dauerhaft offener Boxen - beim
Rock Generator 15 Stueck, man musste am ganzen Panel vorbeiscrollen, um an den
Erzeugen-Knopf zu kommen. Jetzt steht oben nur, was man bei jedem Asset anfasst
(Vorlage, Name, grosser Erzeugen-Knopf, Zufall), der Rest liegt in nummerierten,
eingeklappten Unter-Panels in der Reihenfolge des Arbeitsablaufs:
Rock Gen 9 Unter-Panels (Benennung ... Textur-Bake)
Tree Gen 4 (Wachstum, Blattwerk, Zusatz-Ebenen, Export)
Grass Gen 4 (Form, Haltung, Batch, Kanal-Belegung)
Asset Utils 5 (war schon so, dient als Vorlage)
Schalter, die ein ganzes Unter-Panel steuern (Vertex-Farben backen,
Wachstums-Stufen), sitzen jetzt als Haekchen im Panel-Kopf - Zustand sichtbar
ohne aufzuklappen.
Gemessen statt geglaubt: test_panels.py registriert alle vier Addons, laesst
jedes Panel zeichnen, prueft Tab-Zugehoerigkeit und Auf-/Zugeklappt-Absicht,
dass jeder angebotene Operator existiert - und vor allem, dass JEDE definierte
Property irgendwo erreichbar bleibt (Zweige wie base_shape='BOX' werden dabei
durchgeschaltet). Ergebnis: 0 von 122 Properties beim Umbau verloren.
Test-Suite
----------
Beim Gegenpruefen kam heraus, dass fuenf Tests still veraltet waren und eine
echte Regression nicht gemeldet haetten:
test_rock_gen suchte Objekte mit Praefix "Rock_" - die heissen seit dem
Benennungssystem "Fels_A_M_01". Fand 0 von 3.
test_base_shapes loeschte am Schleifenanfang ALLE Objekte, auch die eigenen
Kopien aus vorigen Durchlaeufen -> StructRNA removed.
test_ecogame_tree erwartete eine feste Namensliste ohne die spaeter
dazugekommenen _Leaf-Objekte.
test_tree_addon dito, ausserdem Zip-Version 1.22.0 fest verdrahtet.
test_leaves Zip-Version 1.23.0 fest verdrahtet.
Drei Muster vermieden statt nur die Symptome geflickt: neue Objekte ueber die
Differenz vorher/nachher finden statt ueber ein Namens-Praefix, Zips per glob
statt mit fester Version, Zusatz-Ebenen beim Zaehlen herausfiltern.
Neu: run_tests.ps1 faehrt die komplette Suite mit einem Befehl (-Filter fuer
einzelne). Genau das fehlte - deshalb war das Veralten nicht aufgefallen.
13 von 13 Tests bestehen.
Nebenbei
--------
build.ps1 ruft server-generate jetzt mit --html auf (browsbare index.html fuer
statisches Hosting). Nachgemessen und im README festgehalten: zwei Zips
derselben Extension-ID sind KEIN Versions-Archiv - server-generate warnt
("archive found with duplicates ... conflicting blender versions"), mehrere
Versionen einer ID gehen nur bei verschiedenen Blender-Bereichen.
Versionen: Rock 2.13.0, Tree 1.24.0, Grass 1.1.0, Asset Utils 1.1.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
"""Registriert alle vier Addons und prueft die Panel-Struktur.
|
||||
|
||||
Warum das eigene Test-Datei ist: py_compile sagt nur, dass die Datei syntaktisch
|
||||
gueltig ist. Ob Blender die Panels ANNIMMT, ist eine andere Frage - bl_parent_id
|
||||
muss auf ein bereits registriertes Panel zeigen, bl_category muss zwischen
|
||||
Eltern- und Unter-Panel uebereinstimmen (sonst landet das Unter-Panel in einem
|
||||
anderen Tab und ist praktisch unsichtbar), und jedes Panel muss zeichnen koennen,
|
||||
ohne zu werfen.
|
||||
|
||||
Aufruf:
|
||||
blender --background --factory-startup --python tests/test_panels.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import importlib.util
|
||||
|
||||
import bpy
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Addon-Datei -> (Haupt-Panel-idname, Anzahl Unter-Panels, die offen sein DUERFEN)
|
||||
#
|
||||
# In den Generatoren sind Unter-Panels Einstellungen, die man selten anfasst -
|
||||
# die gehoeren alle zugeklappt, damit der Erzeugen-Knopf oben sichtbar bleibt.
|
||||
# In Asset Utils ist jedes Unter-Panel ein Arbeitsschritt: 1/2/4 macht man bei
|
||||
# JEDEM Asset, die bleiben offen. Deshalb steht die Erwartung hier pro Addon
|
||||
# statt als pauschale Regel.
|
||||
ADDONS = {
|
||||
"stylized_rock_generator.py": ("VIEW3D_PT_rock_generator", 9,
|
||||
"rock_gen_settings", set()),
|
||||
"stylized_tree_generator.py": ("VIEW3D_PT_tree_generator", 4,
|
||||
"tree_gen_settings", set()),
|
||||
"stylized_grass_generator.py": ("VIEW3D_PT_grass_generator", 4,
|
||||
"grass_gen_settings", set()),
|
||||
"stylized_asset_utils.py": ("VIEW3D_PT_asset_utils", 5,
|
||||
"asset_utils_settings",
|
||||
{"1 - Vorbereiten", "2 - Vertex-Farben",
|
||||
"4 - Pruefen"}),
|
||||
}
|
||||
|
||||
failures = []
|
||||
|
||||
|
||||
def load(path, name):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
class FakeSelf:
|
||||
"""Ersatz fuer die Panel-Instanz: draw() braucht nur self.layout."""
|
||||
|
||||
def __init__(self, layout):
|
||||
self.layout = layout
|
||||
|
||||
|
||||
class FakeLayout:
|
||||
"""Minimal-Layout, das jeden UI-Aufruf schluckt und mitzaehlt.
|
||||
|
||||
Damit laesst sich draw() ohne echten UI-Kontext ausfuehren - der ist im
|
||||
--background-Modus nicht verfuegbar.
|
||||
"""
|
||||
|
||||
def __init__(self, counter):
|
||||
self._c = counter
|
||||
|
||||
def _child(self, *a, **k):
|
||||
return FakeLayout(self._c)
|
||||
|
||||
row = column = box = split = _child
|
||||
|
||||
def prop(self, data, name, **k):
|
||||
# Zugriff erzwingen: schlaegt fehl, wenn die Property nicht existiert.
|
||||
getattr(data, name)
|
||||
self._c["prop"] += 1
|
||||
|
||||
def operator(self, idname, **k):
|
||||
self._c["op"] += 1
|
||||
self._c["ops"].add(idname)
|
||||
return self
|
||||
|
||||
def label(self, **k):
|
||||
self._c["label"] += 1
|
||||
|
||||
def separator(self, **k):
|
||||
pass
|
||||
|
||||
def menu(self, *a, **k):
|
||||
pass
|
||||
|
||||
def template_ID(self, *a, **k):
|
||||
pass
|
||||
|
||||
def __setattr__(self, k, v):
|
||||
# scale_y / active / enabled einfach annehmen
|
||||
object.__setattr__(self, k, v)
|
||||
|
||||
|
||||
class CollectLayout(FakeLayout):
|
||||
"""Wie FakeLayout, sammelt aber die Namen der gezeichneten Properties."""
|
||||
|
||||
def __init__(self, names):
|
||||
FakeLayout.__init__(self, {"prop": 0, "op": 0, "label": 0, "ops": set()})
|
||||
self._names = names
|
||||
|
||||
def _child(self, *a, **k):
|
||||
return CollectLayout(self._names)
|
||||
|
||||
row = column = box = split = _child
|
||||
|
||||
def prop(self, data, name, **k):
|
||||
self._names.add(name)
|
||||
|
||||
|
||||
for filename, (parent_id, want_subs, settings_attr, open_ok) in ADDONS.items():
|
||||
path = os.path.join(ROOT, filename)
|
||||
mod = load(path, filename[:-3])
|
||||
try:
|
||||
mod.register()
|
||||
except Exception as exc:
|
||||
failures.append("%s: register() wirft %s" % (filename, exc))
|
||||
continue
|
||||
|
||||
panels = [c for c in mod.classes if issubclass(c, bpy.types.Panel)]
|
||||
parent = [p for p in panels if getattr(p, "bl_idname", "") == parent_id]
|
||||
if not parent:
|
||||
failures.append("%s: Haupt-Panel %s fehlt" % (filename, parent_id))
|
||||
mod.unregister()
|
||||
continue
|
||||
parent = parent[0]
|
||||
|
||||
subs = [p for p in panels if getattr(p, "bl_parent_id", "") == parent_id]
|
||||
if len(subs) != want_subs:
|
||||
failures.append("%s: %d Unter-Panels, erwartet %d"
|
||||
% (filename, len(subs), want_subs))
|
||||
|
||||
for sub in subs:
|
||||
# Gleicher Tab wie das Eltern-Panel?
|
||||
if sub.bl_category != parent.bl_category:
|
||||
failures.append("%s/%s: bl_category '%s' != Eltern-Tab '%s'"
|
||||
% (filename, sub.__name__, sub.bl_category,
|
||||
parent.bl_category))
|
||||
# Eingeklappt - ausser bei den ausdruecklich als offen gewollten.
|
||||
is_closed = 'DEFAULT_CLOSED' in getattr(sub, "bl_options", set())
|
||||
if not is_closed and sub.bl_label not in open_ok:
|
||||
failures.append("%s/%s (%s): offen, sollte eingeklappt sein"
|
||||
% (filename, sub.__name__, sub.bl_label))
|
||||
if is_closed and sub.bl_label in open_ok:
|
||||
failures.append("%s/%s (%s): eingeklappt, sollte offen sein"
|
||||
% (filename, sub.__name__, sub.bl_label))
|
||||
# Wirklich registriert? Blender haengt bl_rna an registrierte Klassen.
|
||||
if not hasattr(sub, "bl_rna"):
|
||||
failures.append("%s/%s: nicht registriert"
|
||||
% (filename, sub.__name__))
|
||||
|
||||
# Jedes Panel einmal zeichnen lassen.
|
||||
total = {"prop": 0, "op": 0, "label": 0, "ops": set()}
|
||||
for p in [parent] + subs:
|
||||
counter = {"prop": 0, "op": 0, "label": 0, "ops": set()}
|
||||
# FALLE: registrierte Panel-Klassen lassen sich nicht instanziieren
|
||||
# ("bpy_struct.__new__(struct): expected a single argument"). draw() ist
|
||||
# aber eine normale Python-Funktion - self darf alles sein, was .layout hat.
|
||||
inst = FakeSelf(FakeLayout(counter))
|
||||
try:
|
||||
p.draw(inst, bpy.context)
|
||||
if "draw_header" in p.__dict__:
|
||||
p.draw_header(inst, bpy.context)
|
||||
except Exception as exc:
|
||||
failures.append("%s/%s: draw() wirft %s: %s"
|
||||
% (filename, p.__name__, type(exc).__name__, exc))
|
||||
continue
|
||||
if counter["prop"] + counter["op"] + counter["label"] == 0:
|
||||
failures.append("%s/%s: zeichnet gar nichts"
|
||||
% (filename, p.__name__))
|
||||
for k in ("prop", "op", "label"):
|
||||
total[k] += counter[k]
|
||||
total["ops"] |= counter["ops"]
|
||||
|
||||
# Jede definierte Property muss irgendwo im Panel erreichbar sein.
|
||||
#
|
||||
# Das ist der eigentliche Regressionstest beim Umbauen der Panels: beim
|
||||
# Verschieben von Reglern zwischen Panels faellt leicht einer heraus, und
|
||||
# dann gibt es eine Einstellung, an die man in Blender nicht mehr rankommt.
|
||||
# Ein paar Regler haengen an einem Zweig (BOX-Proportionen nur bei
|
||||
# base_shape='BOX'), deshalb werden diese Zweige vorher durchgeschaltet.
|
||||
st = getattr(bpy.context.scene, settings_attr, None)
|
||||
if st is not None:
|
||||
drawn = set()
|
||||
branches = [{}]
|
||||
for attr, values in (("base_shape", ('CUBE', 'BOX', 'ICOSPHERE')),
|
||||
("g_mode", ('SHELLS', 'MOSS')),
|
||||
("shading_mode", ('AUTO', 'FLAT', 'SMOOTH'))):
|
||||
if hasattr(st, attr):
|
||||
branches += [{attr: v} for v in values]
|
||||
for combo in branches:
|
||||
for attr, val in combo.items():
|
||||
try:
|
||||
setattr(st, attr, val)
|
||||
except TypeError:
|
||||
continue
|
||||
for p in [parent] + subs:
|
||||
try:
|
||||
p.draw(FakeSelf(CollectLayout(drawn)), bpy.context)
|
||||
if "draw_header" in p.__dict__:
|
||||
p.draw_header(FakeSelf(CollectLayout(drawn)), bpy.context)
|
||||
except Exception:
|
||||
pass
|
||||
defined = set(type(st).__annotations__)
|
||||
unreachable = sorted(defined - drawn)
|
||||
if unreachable:
|
||||
failures.append("%s: Property(s) in keinem Panel erreichbar: %s"
|
||||
% (filename, ", ".join(unreachable)))
|
||||
|
||||
# Jeder Operator, den ein Panel anbietet, muss es auch geben.
|
||||
for idname in sorted(total["ops"]):
|
||||
grp, _, op = idname.partition(".")
|
||||
if not hasattr(getattr(bpy.ops, grp, None), op):
|
||||
failures.append("%s: Panel ruft unbekannten Operator %s"
|
||||
% (filename, idname))
|
||||
|
||||
print("%-32s %d Unter-Panels %3d Props %2d Buttons %2d Labels"
|
||||
% (filename, len(subs), total["prop"], total["op"], total["label"]))
|
||||
|
||||
mod.unregister()
|
||||
|
||||
print("")
|
||||
if failures:
|
||||
print("ERGEBNIS: %d FEHLER" % len(failures))
|
||||
for f in failures:
|
||||
print(" - " + f)
|
||||
sys.exit(1)
|
||||
print("ERGEBNIS: ALLE PANELS OK")
|
||||
Reference in New Issue
Block a user