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