"""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")