forked from retoor/devplacepy
Update
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -0,0 +1,48 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from tests.conftest import run_async
|
||||
from devplacepy.services.devii.interaction.broker import InteractionBroker
|
||||
|
||||
|
||||
def test_answer_text_completes_open_prompt():
|
||||
captured = {}
|
||||
|
||||
async def wait_site(interaction_id, frame, timeout):
|
||||
captured["id"] = interaction_id
|
||||
outcome = broker.answer_text("13/01/1990")
|
||||
assert outcome["handled"] is True
|
||||
assert outcome["status"] == "submitted"
|
||||
return outcome["result"]
|
||||
|
||||
broker = InteractionBroker("main", wait_site=wait_site)
|
||||
result = run_async(
|
||||
broker.prompt(
|
||||
{
|
||||
"title": "When?",
|
||||
"widgets": [{"type": "date", "name": "day", "label": "Day"}],
|
||||
}
|
||||
)
|
||||
)
|
||||
assert result.status == "submitted"
|
||||
assert result.values == {"day": "13/01/1990"}
|
||||
assert result.meta.get("via") == "text"
|
||||
|
||||
|
||||
def test_answer_text_rejects_free_chat_as_date():
|
||||
async def wait_site(interaction_id, frame, timeout):
|
||||
bad = broker.answer_text("Show me number input then.")
|
||||
assert bad["status"] == "error"
|
||||
good = broker.answer_text("13/01/1990")
|
||||
return good["result"]
|
||||
|
||||
broker = InteractionBroker("main", wait_site=wait_site)
|
||||
result = run_async(
|
||||
broker.prompt(
|
||||
{
|
||||
"title": "When?",
|
||||
"widgets": [{"type": "date", "name": "day", "label": "Day"}],
|
||||
}
|
||||
)
|
||||
)
|
||||
assert result.status == "submitted"
|
||||
assert result.values == {"day": "13/01/1990"}
|
||||
@@ -0,0 +1,93 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
|
||||
from tests.conftest import run_async
|
||||
from devplacepy.services.devii.interaction.broker import InteractionBroker
|
||||
|
||||
|
||||
def test_broker_site_prompt_returns_submitted_values():
|
||||
async def wait_site(interaction_id, frame, timeout):
|
||||
assert frame["title"] == "Deploy?"
|
||||
assert frame["widgets"][0]["type"] == "confirm"
|
||||
return {
|
||||
"status": "submitted",
|
||||
"interaction_id": interaction_id,
|
||||
"values": {"delete": True},
|
||||
}
|
||||
|
||||
broker = InteractionBroker("main", wait_site=wait_site)
|
||||
|
||||
async def run():
|
||||
return await broker.prompt(
|
||||
{
|
||||
"title": "Deploy?",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "confirm",
|
||||
"name": "delete",
|
||||
"label": "Delete",
|
||||
"default": False,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
result = run_async(run())
|
||||
assert result.status == "submitted"
|
||||
assert result.values == {"delete": True}
|
||||
assert result.meta["adapter"] == "site-chat"
|
||||
assert result.meta["degraded"] is False
|
||||
assert broker.open_id() is None
|
||||
|
||||
|
||||
def test_broker_plain_adapter_parses_reply():
|
||||
async def present_plain(menu: str) -> str:
|
||||
assert "yes" in menu.lower() or "Delete" in menu
|
||||
return "yes"
|
||||
|
||||
broker = InteractionBroker("cli", present_plain=present_plain)
|
||||
result = run_async(
|
||||
broker.prompt(
|
||||
{
|
||||
"title": "Delete?",
|
||||
"widgets": [
|
||||
{"type": "confirm", "name": "ok", "label": "OK", "default": False}
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
assert result.status == "submitted"
|
||||
assert result.values == {"ok": True}
|
||||
assert result.meta["degraded"] is True
|
||||
|
||||
|
||||
def test_broker_invalid_args_raise():
|
||||
from devplacepy.services.devii.errors import ToolInputError
|
||||
|
||||
broker = InteractionBroker("main")
|
||||
try:
|
||||
run_async(broker.prompt({"title": "x", "widgets": []}))
|
||||
assert False, "expected ToolInputError"
|
||||
except ToolInputError:
|
||||
pass
|
||||
|
||||
|
||||
def test_broker_timeout_status():
|
||||
async def wait_site(interaction_id, frame, timeout):
|
||||
raise asyncio.TimeoutError()
|
||||
|
||||
broker = InteractionBroker("main", wait_site=wait_site)
|
||||
result = run_async(
|
||||
broker.prompt(
|
||||
{
|
||||
"title": "T?",
|
||||
"timeout_sec": 1,
|
||||
"widgets": [
|
||||
{"type": "confirm", "name": "ok", "label": "OK"}
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
assert result.status == "timeout"
|
||||
assert result.values == {}
|
||||
@@ -0,0 +1,61 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.devii.interaction.capabilities import (
|
||||
CHANNEL_CLI,
|
||||
CHANNEL_SITE_CHAT,
|
||||
CHANNEL_TELEGRAM,
|
||||
CHANNEL_UNKNOWN,
|
||||
channel_id_for_session,
|
||||
context_for,
|
||||
fragment_for,
|
||||
interactions_enabled,
|
||||
)
|
||||
|
||||
|
||||
def test_session_channel_mapping():
|
||||
assert channel_id_for_session("main") == CHANNEL_SITE_CHAT
|
||||
assert channel_id_for_session("docs") == CHANNEL_SITE_CHAT
|
||||
assert channel_id_for_session("telegram") == CHANNEL_TELEGRAM
|
||||
assert channel_id_for_session("cli") == CHANNEL_CLI
|
||||
assert channel_id_for_session("nope") == CHANNEL_UNKNOWN
|
||||
|
||||
|
||||
def test_site_context_exposes_ui_prompt(local_db):
|
||||
ctx = context_for(CHANNEL_SITE_CHAT, owner_kind="user", owner_id="u1")
|
||||
assert ctx.capabilities["rich_widgets"] is True
|
||||
assert "ui_prompt" in ctx.tools
|
||||
assert "ui_cancel" in ctx.tools
|
||||
assert "ui_notify" in ctx.tools
|
||||
assert "interactions_get" in ctx.tools
|
||||
assert "interactions_set" in ctx.tools
|
||||
|
||||
|
||||
def test_open_interaction_gates_to_cancel_only(local_db):
|
||||
ctx = context_for(
|
||||
CHANNEL_SITE_CHAT,
|
||||
open_interaction_id="ix-abc",
|
||||
owner_kind="user",
|
||||
owner_id="u1",
|
||||
)
|
||||
assert "ui_cancel" in ctx.tools
|
||||
assert "ui_prompt" not in ctx.tools
|
||||
assert "interactions_get" in ctx.tools
|
||||
|
||||
|
||||
def test_unknown_channel_has_no_ui_prompt(local_db):
|
||||
ctx = context_for(CHANNEL_UNKNOWN, owner_kind="user", owner_id="u1")
|
||||
assert "ui_prompt" not in ctx.tools
|
||||
assert ctx.capabilities["confirm"] is False
|
||||
assert "interactions_get" in ctx.tools
|
||||
|
||||
|
||||
def test_fragment_is_compact(local_db):
|
||||
text = fragment_for(context_for(CHANNEL_TELEGRAM, owner_kind="guest"))
|
||||
assert "CHANNEL: telegram" in text
|
||||
assert "TOOLS: ui_prompt" in text
|
||||
assert "INTERACTION: none" in text
|
||||
assert len(text) < 600
|
||||
|
||||
|
||||
def test_interactions_enabled_default(local_db):
|
||||
assert interactions_enabled("guest", "") is True
|
||||
@@ -0,0 +1,55 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
|
||||
from tests.conftest import run_async
|
||||
from devplacepy.services.devii.interaction.broker import InteractionBroker
|
||||
from devplacepy.services.devii.interaction.controller import InteractionController
|
||||
|
||||
|
||||
def test_ui_prompt_dispatch():
|
||||
async def wait_site(interaction_id, frame, timeout):
|
||||
return {
|
||||
"status": "submitted",
|
||||
"interaction_id": interaction_id,
|
||||
"values": {"env": "staging"},
|
||||
}
|
||||
|
||||
broker = InteractionBroker("main", wait_site=wait_site)
|
||||
controller = InteractionController(broker)
|
||||
raw = run_async(
|
||||
controller.dispatch(
|
||||
"ui_prompt",
|
||||
{
|
||||
"title": "Env?",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "choice",
|
||||
"name": "env",
|
||||
"label": "Env",
|
||||
"options": [
|
||||
{"value": "staging", "label": "Staging"},
|
||||
{"value": "prod", "label": "Prod"},
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
)
|
||||
data = json.loads(raw)
|
||||
assert data["status"] == "submitted"
|
||||
assert data["values"]["env"] == "staging"
|
||||
|
||||
|
||||
def test_ui_notify_on_site():
|
||||
events = []
|
||||
|
||||
async def emit(payload):
|
||||
events.append(payload)
|
||||
|
||||
broker = InteractionBroker("main", emit=emit)
|
||||
controller = InteractionController(broker)
|
||||
raw = run_async(controller.dispatch("ui_notify", {"text": "Working..."}))
|
||||
data = json.loads(raw)
|
||||
assert data["status"] == "ok"
|
||||
assert events and events[0]["type"] == "status"
|
||||
@@ -0,0 +1,57 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.devii.interaction.markdown import (
|
||||
project_markdown,
|
||||
project_plain_menu,
|
||||
)
|
||||
from devplacepy.services.devii.interaction.schema import validate_prompt_args
|
||||
|
||||
|
||||
def _request():
|
||||
return validate_prompt_args(
|
||||
{
|
||||
"title": "Deploy to staging?",
|
||||
"description": "Ship the current build.",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "choice",
|
||||
"name": "env",
|
||||
"label": "Target environment",
|
||||
"default": "staging",
|
||||
"options": [
|
||||
{"value": "production", "label": "Production"},
|
||||
{"value": "staging", "label": "Staging"},
|
||||
{"value": "dev", "label": "Development"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"name": "notes",
|
||||
"label": "Ship notes",
|
||||
"required": False,
|
||||
"max_length": 200,
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_markdown_projection_has_title_and_id():
|
||||
md = project_markdown(_request(), "ix-a1b2")
|
||||
assert "### Deploy to staging?" in md
|
||||
assert "`staging`" in md
|
||||
assert "<!-- ai-interaction:ix-a1b2 -->" in md
|
||||
assert "[Confirm]" in md
|
||||
|
||||
|
||||
def test_plain_menu_for_confirm():
|
||||
req = validate_prompt_args(
|
||||
{
|
||||
"title": "Delete?",
|
||||
"widgets": [
|
||||
{"type": "confirm", "name": "delete", "label": "Delete", "default": False}
|
||||
],
|
||||
}
|
||||
)
|
||||
text = project_plain_menu(req)
|
||||
assert "yes | no" in text.lower() or "Reply: yes" in text
|
||||
@@ -0,0 +1,117 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.devii.interaction.parse import parse_plain_reply
|
||||
from devplacepy.services.devii.interaction.schema import validate_prompt_args
|
||||
|
||||
|
||||
def test_parse_confirm_yes():
|
||||
req = validate_prompt_args(
|
||||
{
|
||||
"title": "Delete?",
|
||||
"widgets": [{"type": "confirm", "name": "delete", "label": "Delete"}],
|
||||
}
|
||||
)
|
||||
status, values, err = parse_plain_reply(req, "yes")
|
||||
assert status == "submitted"
|
||||
assert values == {"delete": True}
|
||||
assert err is None
|
||||
|
||||
|
||||
def test_parse_choice_by_index():
|
||||
req = validate_prompt_args(
|
||||
{
|
||||
"title": "Env?",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "choice",
|
||||
"name": "env",
|
||||
"label": "Env",
|
||||
"options": [
|
||||
{"value": "production", "label": "Production"},
|
||||
{"value": "staging", "label": "Staging"},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
status, values, err = parse_plain_reply(req, "2")
|
||||
assert status == "submitted"
|
||||
assert values == {"env": "staging"}
|
||||
|
||||
|
||||
def test_parse_cancel():
|
||||
req = validate_prompt_args(
|
||||
{
|
||||
"title": "Env?",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "choice",
|
||||
"name": "env",
|
||||
"label": "Env",
|
||||
"options": [{"value": "a", "label": "A"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
status, values, err = parse_plain_reply(req, "cancel")
|
||||
assert status == "cancelled"
|
||||
assert values == {}
|
||||
|
||||
|
||||
def test_parse_date_european():
|
||||
req = validate_prompt_args(
|
||||
{
|
||||
"title": "When?",
|
||||
"widgets": [{"type": "date", "name": "day", "label": "Day"}],
|
||||
}
|
||||
)
|
||||
status, values, err = parse_plain_reply(req, "13/01/1990")
|
||||
assert status == "submitted"
|
||||
assert values == {"day": "13/01/1990"}
|
||||
assert err is None
|
||||
|
||||
|
||||
def test_parse_date_iso_normalized_to_european():
|
||||
req = validate_prompt_args(
|
||||
{
|
||||
"title": "When?",
|
||||
"widgets": [{"type": "date", "name": "day", "label": "Day"}],
|
||||
}
|
||||
)
|
||||
status, values, err = parse_plain_reply(req, "1990-01-13")
|
||||
assert status == "submitted"
|
||||
assert values == {"day": "13/01/1990"}
|
||||
|
||||
|
||||
def test_parse_confirm_dutch_ja():
|
||||
req = validate_prompt_args(
|
||||
{
|
||||
"title": "Ok?",
|
||||
"widgets": [{"type": "confirm", "name": "ok", "label": "Ok"}],
|
||||
}
|
||||
)
|
||||
status, values, err = parse_plain_reply(req, "ja")
|
||||
assert status == "submitted"
|
||||
assert values == {"ok": True}
|
||||
|
||||
|
||||
def test_parse_choice_by_label():
|
||||
req = validate_prompt_args(
|
||||
{
|
||||
"title": "Lang?",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "choice",
|
||||
"name": "lang",
|
||||
"label": "Language",
|
||||
"options": [
|
||||
{"value": "python", "label": "Python"},
|
||||
{"value": "rust", "label": "Rust"},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
status, values, err = parse_plain_reply(req, "Rust")
|
||||
assert status == "submitted"
|
||||
assert values == {"lang": "rust"}
|
||||
@@ -0,0 +1,61 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.database import get_table, set_setting
|
||||
from devplacepy.services.devii.interaction import prefs
|
||||
from devplacepy.utils.accounts import register_account
|
||||
|
||||
|
||||
def _user(username="ixpref1"):
|
||||
try:
|
||||
register_account(username, f"{username}@t.dev", "secret123")
|
||||
except Exception:
|
||||
pass
|
||||
return get_table("users").find_one(username=username)
|
||||
|
||||
|
||||
def test_admin_default_applies_when_user_inherits(local_db):
|
||||
set_setting("devii_interactions_default", "1")
|
||||
user = _user("ixpref_inherit")
|
||||
get_table("users").update(
|
||||
{"uid": user["uid"], "interactions_enabled": -1}, ["uid"]
|
||||
)
|
||||
assert prefs.effective_for("user", user["uid"]) is True
|
||||
snap = prefs.snapshot("user", user["uid"])
|
||||
assert snap["source"] == "default"
|
||||
assert snap["override"] is None
|
||||
|
||||
|
||||
def test_user_override_off(local_db):
|
||||
set_setting("devii_interactions_default", "1")
|
||||
user = _user("ixpref_off")
|
||||
prefs.set_user_pref(user["uid"], False)
|
||||
assert prefs.effective_for("user", user["uid"]) is False
|
||||
snap = prefs.snapshot("user", user["uid"])
|
||||
assert snap["source"] == "user"
|
||||
assert snap["override"] is False
|
||||
assert snap["default"] is True
|
||||
|
||||
|
||||
def test_user_override_on_when_default_off(local_db):
|
||||
set_setting("devii_interactions_default", "0")
|
||||
user = _user("ixpref_on")
|
||||
prefs.set_user_pref(user["uid"], True)
|
||||
assert prefs.effective_for("user", user["uid"]) is True
|
||||
assert prefs.admin_default() is False
|
||||
|
||||
|
||||
def test_reset_returns_to_default(local_db):
|
||||
set_setting("devii_interactions_default", "0")
|
||||
user = _user("ixpref_reset")
|
||||
prefs.set_user_pref(user["uid"], True)
|
||||
snap = prefs.set_user_pref(user["uid"], None)
|
||||
assert snap["source"] == "default"
|
||||
assert snap["enabled"] is False
|
||||
assert get_table("users").find_one(uid=user["uid"])["interactions_enabled"] == -1
|
||||
|
||||
|
||||
def test_guest_uses_admin_default(local_db):
|
||||
set_setting("devii_interactions_default", "0")
|
||||
assert prefs.effective_for("guest", "cookie") is False
|
||||
set_setting("devii_interactions_default", "1")
|
||||
assert prefs.effective_for("guest", "cookie") is True
|
||||
@@ -0,0 +1,96 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import pytest
|
||||
|
||||
from devplacepy.services.devii.errors import ToolInputError
|
||||
from devplacepy.services.devii.interaction.schema import (
|
||||
validate_prompt_args,
|
||||
)
|
||||
|
||||
|
||||
def test_valid_confirm_prompt():
|
||||
req = validate_prompt_args(
|
||||
{
|
||||
"title": "Delete archive row?",
|
||||
"description": "Remote files are untouched.",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "confirm",
|
||||
"name": "delete",
|
||||
"label": "Delete archive row",
|
||||
"default": False,
|
||||
}
|
||||
],
|
||||
"submit_label": "Delete",
|
||||
"cancel_label": "Keep",
|
||||
}
|
||||
)
|
||||
assert req.title.startswith("Delete")
|
||||
assert req.widgets[0].type == "confirm"
|
||||
assert req.widgets[0].name == "delete"
|
||||
|
||||
|
||||
def test_choice_requires_options():
|
||||
with pytest.raises(ToolInputError):
|
||||
validate_prompt_args(
|
||||
{
|
||||
"title": "Env?",
|
||||
"widgets": [{"type": "choice", "name": "env", "label": "Env"}],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_rejects_empty_widgets():
|
||||
with pytest.raises(ToolInputError):
|
||||
validate_prompt_args({"title": "x", "widgets": []})
|
||||
|
||||
|
||||
def test_rejects_bad_option_value():
|
||||
with pytest.raises(ToolInputError):
|
||||
validate_prompt_args(
|
||||
{
|
||||
"title": "Pick",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "choice",
|
||||
"name": "x",
|
||||
"label": "X",
|
||||
"options": [{"value": "BAD VALUE!", "label": "Bad"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_group_nesting_limit():
|
||||
leaf = {
|
||||
"type": "text",
|
||||
"name": "a",
|
||||
"label": "A",
|
||||
"required": False,
|
||||
}
|
||||
deep = {
|
||||
"type": "group",
|
||||
"label": "G1",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "group",
|
||||
"label": "G2",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "group",
|
||||
"label": "G3",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "group",
|
||||
"label": "G4",
|
||||
"widgets": [leaf],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
with pytest.raises(ToolInputError):
|
||||
validate_prompt_args({"title": "Deep", "widgets": [deep]})
|
||||
Reference in New Issue
Block a user