generated from coulomb/repo-seed
engine/extension.py: Extension contract (id/provides/declares_types/depends_on/ conflicts_with + bound hooks), ExtensionRuntime (register-with-verification, activate = dependency closure + conflict + type-collision checks rejecting impossible profiles), and ActiveExtensions with deterministic (topological, id-tie) hook dispatch — transform hooks chain, collect hooks gather. 9 tests green, coverage floor held, pyflakes clean. Marks T2 done. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
110 lines
3.2 KiB
Python
110 lines
3.2 KiB
Python
"""Tests for the typed-extension runtime (SHARD-WP-0014 T2)."""
|
|
|
|
import pytest
|
|
|
|
from shard_wiki.engine import Extension, ExtensionError, ExtensionRuntime, Hook
|
|
|
|
|
|
class Upper(Extension):
|
|
id = "ext.upper"
|
|
declares_types = ("upper",)
|
|
|
|
def hooks(self):
|
|
return {Hook.ON_WRITE: lambda body, ctx: body.upper()}
|
|
|
|
|
|
class Bang(Extension):
|
|
id = "ext.bang"
|
|
depends_on = ("ext.upper",)
|
|
|
|
def hooks(self):
|
|
return {Hook.ON_WRITE: lambda body, ctx: body + "!"}
|
|
|
|
|
|
class Profiler(Extension):
|
|
id = "ext.profiler"
|
|
|
|
def hooks(self):
|
|
return {Hook.ON_PROFILE: lambda payload, ctx: {"structure": "typed"}}
|
|
|
|
|
|
def _runtime(*exts):
|
|
rt = ExtensionRuntime()
|
|
for e in exts:
|
|
rt.register(e)
|
|
return rt
|
|
|
|
|
|
def test_register_rejects_bad_id_and_noncallable_hook():
|
|
rt = ExtensionRuntime()
|
|
class BadId(Extension):
|
|
id = "upper" # missing 'ext.' prefix
|
|
with pytest.raises(ExtensionError, match="ext."):
|
|
rt.register(BadId())
|
|
|
|
class Liar(Extension):
|
|
id = "ext.liar"
|
|
def hooks(self):
|
|
return {Hook.ON_WRITE: "not-callable"} # type: ignore[dict-item]
|
|
with pytest.raises(ExtensionError, match="not callable"):
|
|
rt.register(Liar())
|
|
|
|
|
|
def test_transform_hook_chains_in_dependency_order():
|
|
rt = _runtime(Upper(), Bang())
|
|
active = rt.activate({"ext.bang"}) # pulls ext.upper via depends_on
|
|
assert set(active.ids) == {"ext.upper", "ext.bang"}
|
|
# upper (dependency) runs before bang (dependent): "hi" -> "HI" -> "HI!"
|
|
assert active.handlers(Hook.ON_WRITE) == ("ext.upper", "ext.bang")
|
|
assert active.dispatch_transform(Hook.ON_WRITE, "hi") == "HI!"
|
|
|
|
|
|
def test_dependency_closure_is_automatic():
|
|
rt = _runtime(Upper(), Bang())
|
|
assert set(rt.activate({"ext.bang"}).ids) == {"ext.upper", "ext.bang"}
|
|
|
|
|
|
def test_unknown_extension_rejected():
|
|
with pytest.raises(ExtensionError, match="unknown"):
|
|
ExtensionRuntime().activate({"ext.ghost"})
|
|
|
|
|
|
def test_conflict_rejected():
|
|
class A(Extension):
|
|
id = "ext.a"
|
|
conflicts_with = ("ext.b",)
|
|
class B(Extension):
|
|
id = "ext.b"
|
|
with pytest.raises(ExtensionError, match="conflicts"):
|
|
_runtime(A(), B()).activate({"ext.a", "ext.b"})
|
|
|
|
|
|
def test_type_collision_rejected():
|
|
class S1(Extension):
|
|
id = "ext.s1"
|
|
declares_types = ("record",)
|
|
class S2(Extension):
|
|
id = "ext.s2"
|
|
declares_types = ("record",)
|
|
with pytest.raises(ExtensionError, match="type collision"):
|
|
_runtime(S1(), S2()).activate({"ext.s1", "ext.s2"})
|
|
|
|
|
|
def test_collect_hook_gathers_contributions():
|
|
active = _runtime(Profiler()).activate({"ext.profiler"})
|
|
assert active.dispatch_collect(Hook.ON_PROFILE) == [{"structure": "typed"}]
|
|
|
|
|
|
def test_wrong_dispatch_kind_errors():
|
|
active = _runtime(Profiler()).activate({"ext.profiler"})
|
|
with pytest.raises(ExtensionError, match="not a transform hook"):
|
|
active.dispatch_transform(Hook.ON_PROFILE, "x")
|
|
|
|
|
|
def test_unmet_dependency_rejected():
|
|
# Bang depends on ext.upper, but only Bang is registered.
|
|
rt = ExtensionRuntime()
|
|
rt.register(Bang())
|
|
with pytest.raises(ExtensionError, match="unmet dependency|unknown"):
|
|
rt.activate({"ext.bang"})
|