refactor: complete server-first item schema wiring and plugin contract checks
This commit is contained in:
@@ -3,11 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, cast
|
||||
from typing import TypeAlias, cast
|
||||
|
||||
from .items.registry import ITEM_MODULES, ITEM_TYPE_ORDER
|
||||
|
||||
ItemType = Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"]
|
||||
ItemType: TypeAlias = str
|
||||
ITEM_TYPE_SEQUENCE: tuple[ItemType, ...] = cast(tuple[ItemType, ...], ITEM_TYPE_ORDER)
|
||||
ITEM_TYPE_LABELS: dict[ItemType, str] = {item_type: ITEM_MODULES[item_type].LABEL for item_type in ITEM_TYPE_SEQUENCE}
|
||||
ITEM_TYPE_EDITABLE_PROPERTIES: dict[ItemType, tuple[str, ...]] = {
|
||||
@@ -75,15 +75,6 @@ ITEM_DEFINITIONS: dict[ItemType, ItemDefinition] = {
|
||||
for item_type in ITEM_TYPE_SEQUENCE
|
||||
}
|
||||
|
||||
ITEM_PROPERTY_OPTIONS: dict[str, tuple[str, ...]] = {
|
||||
"mediaEffect": RADIO_EFFECT_OPTIONS,
|
||||
"emitEffect": RADIO_EFFECT_OPTIONS,
|
||||
"mediaChannel": RADIO_CHANNEL_OPTIONS,
|
||||
"timeZone": CLOCK_TIME_ZONE_OPTIONS,
|
||||
"instrument": PIANO_INSTRUMENT_OPTIONS,
|
||||
"voiceMode": PIANO_VOICE_MODE_OPTIONS,
|
||||
}
|
||||
|
||||
ITEM_TYPE_TOOLTIPS: dict[ItemType, str] = {
|
||||
item_type: ITEM_MODULES[item_type].TOOLTIP for item_type in ITEM_TYPE_SEQUENCE
|
||||
}
|
||||
@@ -125,6 +116,12 @@ def get_item_definition(item_type: ItemType) -> ItemDefinition:
|
||||
return ITEM_DEFINITIONS[item_type]
|
||||
|
||||
|
||||
def is_known_item_type(item_type: str) -> bool:
|
||||
"""Return whether a string item type id exists in discovered plugins."""
|
||||
|
||||
return item_type in ITEM_DEFINITIONS
|
||||
|
||||
|
||||
def get_item_use_cooldown_ms(item_type: ItemType) -> int:
|
||||
"""Return validated global use cooldown in milliseconds for an item type."""
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@ import time
|
||||
import uuid
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from .client import ClientConnection
|
||||
from .item_catalog import get_item_definition
|
||||
from .models import PersistedWorldItem, WorldItem
|
||||
@@ -36,7 +34,7 @@ class ItemService:
|
||||
|
||||
return int(time.time() * 1000)
|
||||
|
||||
def default_item(self, client: ClientConnection, item_type: Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"]) -> WorldItem:
|
||||
def default_item(self, client: ClientConnection, item_type: str) -> WorldItem:
|
||||
"""Create a new server-authoritative item at the caller's position."""
|
||||
|
||||
item_def = get_item_definition(item_type)
|
||||
|
||||
@@ -60,6 +60,6 @@ PARAM_KEYS: tuple[str, ...] = ("timeZone", "use24Hour")
|
||||
|
||||
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
||||
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
|
||||
"timeZone": {"valueType": "list", "tooltip": "Timezone used when the clock speaks time."},
|
||||
"timeZone": {"valueType": "list", "tooltip": "Timezone used when the clock speaks time.", "options": list(TIME_ZONE_OPTIONS)},
|
||||
"use24Hour": {"valueType": "boolean", "tooltip": "Use 24 hour format instead of AM/PM."},
|
||||
}
|
||||
|
||||
@@ -64,8 +64,8 @@ DEFAULT_ENVELOPE_BY_INSTRUMENT: dict[str, tuple[int, int, int, int, str, int]] =
|
||||
|
||||
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
||||
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
|
||||
"instrument": {"valueType": "list", "tooltip": "Instrument voice used when playing this piano."},
|
||||
"voiceMode": {"valueType": "list", "tooltip": "Mono plays one note at a time; poly allows chords."},
|
||||
"instrument": {"valueType": "list", "tooltip": "Instrument voice used when playing this piano.", "options": list(INSTRUMENT_OPTIONS)},
|
||||
"voiceMode": {"valueType": "list", "tooltip": "Mono plays one note at a time; poly allows chords.", "options": list(VOICE_MODE_OPTIONS)},
|
||||
"octave": {
|
||||
"valueType": "number",
|
||||
"tooltip": "Shifts played notes in octaves. -1 is one octave down.",
|
||||
|
||||
@@ -55,8 +55,8 @@ PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
||||
"tooltip": "Playback media volume percent for this radio.",
|
||||
"range": {"min": 0, "max": 100, "step": 1},
|
||||
},
|
||||
"mediaChannel": {"valueType": "list", "tooltip": "Select how the station audio channels are rendered."},
|
||||
"mediaEffect": {"valueType": "list", "tooltip": "Select the active radio effect."},
|
||||
"mediaChannel": {"valueType": "list", "tooltip": "Select how the station audio channels are rendered.", "options": list(CHANNEL_OPTIONS)},
|
||||
"mediaEffect": {"valueType": "list", "tooltip": "Select the active radio effect.", "options": list(EFFECT_OPTIONS)},
|
||||
"mediaEffectValue": {
|
||||
"valueType": "number",
|
||||
"tooltip": "Amount for the selected effect.",
|
||||
|
||||
@@ -83,7 +83,7 @@ PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
||||
"tooltip": "Playback tempo percent for emitted sound. 50 is normal, 0 is half, 100 is double. Using speed and tempo together may sound weird.",
|
||||
"range": {"min": 0, "max": 100, "step": 0.1},
|
||||
},
|
||||
"emitEffect": {"valueType": "list", "tooltip": "Effect applied to emitted sound."},
|
||||
"emitEffect": {"valueType": "list", "tooltip": "Effect applied to emitted sound.", "options": list(EFFECT_OPTIONS)},
|
||||
"emitEffectValue": {
|
||||
"valueType": "number",
|
||||
"tooltip": "Amount for emit effect.",
|
||||
|
||||
@@ -42,7 +42,7 @@ class PingPacket(BasePacket):
|
||||
|
||||
class ItemAddPacket(BasePacket):
|
||||
type: Literal["item_add"]
|
||||
itemType: Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"]
|
||||
itemType: str = Field(min_length=1)
|
||||
|
||||
|
||||
class ItemPickupPacket(BasePacket):
|
||||
@@ -173,7 +173,7 @@ class NicknameResultPacket(BasePacket):
|
||||
|
||||
class WorldItem(BaseModel):
|
||||
id: str
|
||||
type: Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"]
|
||||
type: str = Field(min_length=1)
|
||||
title: str
|
||||
x: int
|
||||
y: int
|
||||
@@ -191,7 +191,7 @@ class WorldItem(BaseModel):
|
||||
class PersistedWorldItem(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
id: str
|
||||
type: Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"]
|
||||
type: str = Field(min_length=1)
|
||||
title: str
|
||||
x: int
|
||||
y: int
|
||||
|
||||
@@ -25,14 +25,15 @@ from .config import load_config
|
||||
from .item_catalog import (
|
||||
CLOCK_DEFAULT_TIME_ZONE,
|
||||
CLOCK_TIME_ZONE_OPTIONS,
|
||||
ITEM_PROPERTY_OPTIONS,
|
||||
ITEM_TYPE_EDITABLE_PROPERTIES,
|
||||
ITEM_TYPE_LABELS,
|
||||
ITEM_TYPE_PROPERTY_METADATA,
|
||||
ITEM_TYPE_SEQUENCE,
|
||||
ITEM_TYPE_TOOLTIPS,
|
||||
get_item_definition,
|
||||
get_item_global_properties,
|
||||
get_item_use_cooldown_ms,
|
||||
is_known_item_type,
|
||||
)
|
||||
from .item_type_handlers import get_item_type_handler
|
||||
from .item_service import ItemService
|
||||
@@ -708,18 +709,13 @@ class SignalingServer:
|
||||
item_types: list[dict] = []
|
||||
for item_type in ITEM_TYPE_SEQUENCE:
|
||||
editable = list(ITEM_TYPE_EDITABLE_PROPERTIES.get(item_type, ("title",)))
|
||||
property_options: dict[str, list[str]] = {}
|
||||
for key in editable:
|
||||
options = ITEM_PROPERTY_OPTIONS.get(key)
|
||||
if options:
|
||||
property_options[key] = list(options)
|
||||
item_types.append(
|
||||
{
|
||||
"type": item_type,
|
||||
"label": ITEM_TYPE_LABELS.get(item_type, item_type),
|
||||
"tooltip": ITEM_TYPE_TOOLTIPS.get(item_type),
|
||||
"capabilities": list(get_item_definition(item_type).capabilities),
|
||||
"editableProperties": editable,
|
||||
"propertyOptions": property_options,
|
||||
"propertyMetadata": ITEM_TYPE_PROPERTY_METADATA.get(item_type, {}),
|
||||
"globalProperties": get_item_global_properties(item_type),
|
||||
}
|
||||
@@ -897,6 +893,9 @@ class SignalingServer:
|
||||
return
|
||||
|
||||
if isinstance(packet, ItemAddPacket):
|
||||
if not is_known_item_type(packet.itemType):
|
||||
await self._send_item_result(client, False, "add", "Unknown item type.")
|
||||
return
|
||||
item = self.item_service.default_item(client, packet.itemType)
|
||||
self.item_service.add_item(item)
|
||||
await self._broadcast_item(item)
|
||||
|
||||
32
server/tests/test_item_plugin_contract.py
Normal file
32
server/tests/test_item_plugin_contract.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.items.registry import ITEM_PLUGINS
|
||||
|
||||
|
||||
def test_item_plugins_expose_expected_contract() -> None:
|
||||
for plugin in ITEM_PLUGINS:
|
||||
module = plugin.module
|
||||
assert isinstance(plugin.type, str) and plugin.type
|
||||
assert isinstance(plugin.order, int)
|
||||
assert callable(getattr(module, "validate_update", None))
|
||||
assert callable(getattr(module, "use_item", None))
|
||||
assert isinstance(getattr(module, "LABEL", ""), str)
|
||||
assert isinstance(getattr(module, "TOOLTIP", ""), str)
|
||||
assert isinstance(getattr(module, "EDITABLE_PROPERTIES", ()), tuple)
|
||||
assert isinstance(getattr(module, "CAPABILITIES", ()), tuple)
|
||||
assert isinstance(getattr(module, "DEFAULT_PARAMS", {}), dict)
|
||||
assert isinstance(getattr(module, "PROPERTY_METADATA", {}), dict)
|
||||
|
||||
|
||||
def test_item_plugin_folders_have_required_files() -> None:
|
||||
base_dir = Path(__file__).resolve().parents[1] / "app" / "items" / "types"
|
||||
for plugin in ITEM_PLUGINS:
|
||||
type_dir = base_dir / plugin.type
|
||||
assert type_dir.is_dir()
|
||||
assert (type_dir / "definition.py").is_file()
|
||||
assert (type_dir / "validator.py").is_file()
|
||||
assert (type_dir / "actions.py").is_file()
|
||||
assert (type_dir / "module.py").is_file()
|
||||
assert (type_dir / "plugin.py").is_file()
|
||||
@@ -33,15 +33,16 @@ def test_ui_definitions_are_complete_for_all_item_types() -> None:
|
||||
assert isinstance(entry.get("type"), str)
|
||||
assert isinstance(entry.get("label"), str)
|
||||
assert isinstance(entry.get("editableProperties"), list)
|
||||
assert isinstance(entry.get("capabilities"), list)
|
||||
assert isinstance(entry.get("propertyMetadata"), dict)
|
||||
assert isinstance(entry.get("propertyOptions"), dict)
|
||||
assert isinstance(entry.get("globalProperties"), dict)
|
||||
|
||||
editable_properties = entry["editableProperties"]
|
||||
capabilities = entry["capabilities"]
|
||||
property_metadata = entry["propertyMetadata"]
|
||||
property_options = entry["propertyOptions"]
|
||||
global_properties = entry["globalProperties"]
|
||||
|
||||
assert capabilities
|
||||
assert required_global_keys.issubset(set(global_properties.keys()))
|
||||
for property_key in editable_properties:
|
||||
if property_key == "title":
|
||||
@@ -50,7 +51,7 @@ def test_ui_definitions_are_complete_for_all_item_types() -> None:
|
||||
metadata = property_metadata[property_key]
|
||||
assert isinstance(metadata, dict)
|
||||
if metadata.get("valueType") == "list":
|
||||
options = property_options.get(property_key)
|
||||
options = metadata.get("options")
|
||||
assert isinstance(options, list)
|
||||
assert options
|
||||
|
||||
|
||||
@@ -83,3 +83,24 @@ async def test_broadcast_fanout_is_concurrent(monkeypatch: pytest.MonkeyPatch) -
|
||||
assert ws1 in send_started_at
|
||||
assert ws2 in send_started_at
|
||||
assert abs(send_started_at[ws1] - send_started_at[ws2]) < 0.02
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_item_add_rejects_unknown_type(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
server = SignalingServer("127.0.0.1", 8765, None, None)
|
||||
ws = _fake_ws()
|
||||
client = ClientConnection(websocket=ws, id="u1", nickname="tester", x=5, y=6)
|
||||
server.clients[ws] = client
|
||||
|
||||
send_payloads: list[object] = []
|
||||
|
||||
async def fake_send(websocket: ServerConnection, packet: object) -> None:
|
||||
send_payloads.append(packet)
|
||||
|
||||
monkeypatch.setattr(server, "_send", fake_send)
|
||||
|
||||
await server._handle_message(client, json.dumps({"type": "item_add", "itemType": "not_a_type"}))
|
||||
|
||||
assert send_payloads
|
||||
assert send_payloads[-1].ok is False
|
||||
assert "unknown item type" in send_payloads[-1].message.lower()
|
||||
|
||||
Reference in New Issue
Block a user