"""Tests über ALLE eingebauten Pflicht-Plugins (PLAN.md §15, §29.1). Parametrisiert über jedes gefundene Builtin-Plugin: - Manifest validiert (inkl. Shader-Existenz je Backend) - kind stimmt zur Kategorie (generator/filter) - alle drei Backends d3d11/gl/gles deklariert (§12.6 Backend-Vertrag) - max. 8 DMX-Slots gesamt (§14.7) - adaptive_quality mit mindestens 3 Varianten (§15.1/§15.2 AQ-Regeln) - Standard-Uniform-Satz in jedem Shader deklariert (§14.4) Vollständigkeitsprüfung (§15.1/§15.2): exakt 10 Generatoren und 14 Filter mit den normativen Plugin-IDs sind vorhanden. """ from __future__ import annotations from pathlib import Path import pytest from hms_plugin_sdk import load_manifest REPO = Path(__file__).resolve().parents[2] BUILTIN = REPO / "plugins" / "builtin" REQUIRED_GENERATORS = { "com.hms.generator.solid", "com.hms.generator.gradient", "com.hms.generator.checker_grid", "com.hms.generator.stripes_chaser", "com.hms.generator.noise_clouds", "com.hms.generator.plasma", "com.hms.generator.wave_bars", "com.hms.generator.shapes", "com.hms.generator.drops_ripples", "com.hms.generator.starfield", } REQUIRED_FILTERS = { "com.hms.fx.transform2d", "com.hms.fx.color_adjust", "com.hms.fx.gradient_map", "com.hms.fx.blur_sharpen", "com.hms.fx.pixelate_quantize", "com.hms.fx.mirror_tile", "com.hms.fx.kaleidoscope", "com.hms.fx.wave_displace", "com.hms.fx.rgb_split", "com.hms.fx.glow_bloom", "com.hms.fx.edge_emboss", "com.hms.fx.vignette", "com.hms.fx.strobe_pulse", "com.hms.fx.feedback_trails", } def _plugin_dirs() -> list[Path]: dirs: list[Path] = [] for category in ("generators", "filters"): dirs.extend(sorted((BUILTIN / category).glob("com.*"))) return dirs def _shaders_of(plugin_dir: Path) -> list[Path]: return [p for p in (plugin_dir / "shaders").rglob("*") if p.is_file()] # ---------- Vollständigkeit (§15.1, §15.2) ---------- def test_all_mandatory_generators_present() -> None: found = {d.name for d in _plugin_dirs()} missing = REQUIRED_GENERATORS - found assert not missing, f"fehlende Pflicht-Generatoren: {sorted(missing)}" def test_all_mandatory_filters_present() -> None: found = {d.name for d in _plugin_dirs()} missing = REQUIRED_FILTERS - found assert not missing, f"fehlende Pflicht-Filter: {sorted(missing)}" def test_exactly_the_mandatory_set() -> None: """Keine unautorisierten Extras im Pflicht-Verzeichnis (Verwechselungs- schutz; Beispiele leben in plugins/examples).""" found = {d.name for d in _plugin_dirs()} expected = REQUIRED_GENERATORS | REQUIRED_FILTERS assert found == expected, ( f"unerwartete Differenz: extra={sorted(found - expected)} " f"fehlt={sorted(expected - found)}" ) # ---------- Parametrisierte Einzelprüfungen ---------- @pytest.mark.parametrize("plugin_dir", _plugin_dirs(), ids=lambda p: p.name) class TestBuiltinPlugin: def test_manifest_validates(self, plugin_dir: Path) -> None: manifest, errors = load_manifest(plugin_dir) assert errors == [], errors assert manifest["vendor"] == "HMS" assert manifest["failure_mode"] == "bypass" assert manifest["schema_version"] == 1 assert manifest["api_version"] == 1 def test_kind_matches_category(self, plugin_dir: Path) -> None: manifest, _ = load_manifest(plugin_dir) category = plugin_dir.parent.name expected = "generator" if category == "generators" else "filter" assert manifest["kind"] == expected def test_all_backends_declared(self, plugin_dir: Path) -> None: """§12.6 Backend-Vertrag: V1-Plugins liefern d3d11, gl und gles.""" manifest, _ = load_manifest(plugin_dir) assert set(manifest["entrypoints"]) == {"d3d11", "gl", "gles"} assert set(manifest["capabilities"]["supported_backends"]) == { "d3d11", "gl", "gles", } def test_dmx_slot_footprint_max_8(self, plugin_dir: Path) -> None: """§14.7: maximal 8 generische DMX-Slots je Plugin.""" manifest, _ = load_manifest(plugin_dir) total = sum(len(p.get("dmx_slots", [])) for p in manifest["parameters"]) assert total <= 8, f"{plugin_dir.name}: {total} DMX-Slots > 8" def test_adaptive_quality_variants(self, plugin_dir: Path) -> None: """§15.1/§15.2: AQ mit mindestens 3 Varianten; Parametersemantik bleibt über Varianten unverändert (semantic_parameters_unchanged darf nur AQ-interne Größen nennen, nie semantische Parameter wegnehmen).""" manifest, _ = load_manifest(plugin_dir) aq = manifest.get("adaptive_quality") assert aq is not None, "adaptive_quality fehlt" ids = [v["id"] for v in aq["variants"]] assert len(ids) >= 3, f"nur {len(ids)} AQ-Varianten" def test_shader_uniform_contract(self, plugin_dir: Path) -> None: """§14.4: Standard-Inputs sind in jedem Shader deklariert; Filter sampeln die Eingabetextur, Generatoren deklarieren sie (Uniform-Satz identisch), auch wenn sie sie nicht nutzen.""" shaders = _shaders_of(plugin_dir) assert shaders, "keine Shader-Dateien" for shader in shaders: src = shader.read_text(encoding="utf-8") for uniform in ("u_resolution", "u_time_seconds", "u_layer_opacity"): assert uniform in src, f"{shader.name}: {uniform} fehlt" def test_generator_shader_has_no_input_sampling(self, plugin_dir: Path) -> None: """Generatoren erzeugen Inhalte ohne Eingangsbild (§12.3): der Shader darf die Eingabetextur nicht als Quelle sampeln.""" manifest, _ = load_manifest(plugin_dir) if manifest["kind"] != "generator": return for shader in _shaders_of(plugin_dir): src = shader.read_text(encoding="utf-8") sample_calls = ( src.count("u_input_texture.Sample") + src.count("texture(u_input_texture") + src.count("texture2D(u_input_texture") ) assert sample_calls == 0, ( f"{plugin_dir.name}/{shader.name}: Generator sampelt u_input_texture" ) def test_filter_mix_zero_is_free_bypass(self, plugin_dir: Path) -> None: """§15.3: jeder Filter besitzt mix; Mix 0 bypassed kostengünstig.""" manifest, _ = load_manifest(plugin_dir) if manifest["kind"] != "filter": return ids = [p["id"] for p in manifest["parameters"]] assert "mix" in ids, f"{plugin_dir.name}: mix-Parameter fehlt"