"""FlowDeck — AI Writing Assist tests (v5.9.0). Covers the offline writing service (write / summarize / translate / continue / autocomplete / properties), the `/api/agent/writing` + `/api/agent/writing/properties` endpoints, and the editor/database front-end wiring (slash commands, inline autocomplete, AI property fill). """ import asyncio import os import tempfile import pytest from conftest import login_test_client from fastapi.testclient import TestClient @pytest.fixture def client(): db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False) db_path = db_file.name db_file.close() os.environ["DATABASE_URL"] = f"sqlite:///{db_path}" os.environ["APP_SECRET_KEY"] = "test-secret-for-ai-writing" os.environ["RATE_LIMIT_ENABLED"] = "false" os.environ["LLM_PROVIDER"] = "offline" from app.config import settings from app.db import get_conn, init_db from app.main import app from app.password_utils import hash_password settings.database_url = f"sqlite:///{db_path}" settings.llm_provider = "offline" init_db() with get_conn() as conn: conn.execute( "INSERT OR IGNORE INTO users (login, full_name, email, password_hash, is_admin) " "VALUES ('admin', 'Admin', '', ?, 1)", (hash_password("test"),), ) conn.commit() yield login_test_client(TestClient(app)) try: os.unlink(db_path) except FileNotFoundError: pass def _run(coro): return asyncio.run(coro) # ── Service: constants & validation ── def test_writing_actions_constant(client): from app.services.ai_writing import WRITING_ACTIONS for action in ("write", "summarize", "translate", "continue", "autocomplete", "properties"): assert action in WRITING_ACTIONS def test_unknown_action_raises(client): from app.services.ai_writing import AIWritingService svc = AIWritingService() with pytest.raises(ValueError): _run(svc.run("nope")) # ── Service: offline fallbacks ── def test_write_offline(client): from app.services.ai_writing import AIWritingService res = _run(AIWritingService().run("write", prompt="Guide de démarrage")) assert res["ok"] is True assert res["action"] == "write" assert res["text"].strip() assert res["offline"] is True def test_summarize_offline(client): from app.services.ai_writing import AIWritingService context = "# Titre\n\nCeci est un paragraphe détaillé qui explique le sujet du document.\n\n## Section\n\nEncore du contenu pour le résumé automatique." res = _run(AIWritingService().run("summarize", context=context)) assert res["ok"] is True assert "Résumé" in res["text"] assert "Points clés" in res["text"] def test_summarize_offline_empty_context(client): from app.services.ai_writing import AIWritingService res = _run(AIWritingService().run("summarize", context="")) assert res["ok"] is True assert res["text"].strip() def test_translate_offline(client): from app.services.ai_writing import AIWritingService res = _run(AIWritingService().run("translate", context="Bonjour le monde", target_language="English")) assert res["ok"] is True assert "English" in res["text"] or "Traduction" in res["text"] def test_continue_offline(client): from app.services.ai_writing import AIWritingService res = _run(AIWritingService().run("continue", context="Intro du document.")) assert res["ok"] is True assert res["text"].strip() def test_autocomplete_offline_short_prefix(client): from app.services.ai_writing import AIWritingService res = _run(AIWritingService().run("autocomplete", prefix="court")) assert res["ok"] is True assert res["text"] == "" def test_autocomplete_offline_long_prefix(client): from app.services.ai_writing import AIWritingService res = _run(AIWritingService().run("autocomplete", prefix="Voici une phrase suffisamment longue pour déclencher")) assert res["ok"] is True assert res["text"].strip() # The completion must NOT echo the prompt prefix. assert "suffisamment longue" not in res["text"] # ── Service: AI properties ── def test_properties_offline_defaults(client): from app.services.ai_writing import AIWritingService props = [{"name": "Status", "type": "select"}, {"name": "Priority", "type": "select"}, {"name": "Résumé", "type": "text"}] res = _run(AIWritingService().run("properties", title="Refonte du site", context="Refonte complète du site vitrine de l'entreprise.", properties=props)) assert res["ok"] is True assert res["suggestions"]["Status"] == "To do" assert res["suggestions"]["Priority"] == "Medium" assert res["suggestions"]["Résumé"] def test_properties_offline_ignores_unknown(client): from app.services.ai_writing import AIWritingService props = [{"name": "Couleur préférée", "type": "text"}] res = _run(AIWritingService().run("properties", title="T", properties=props)) assert res["ok"] is True assert res["suggestions"] == {} def test_properties_empty_list(client): from app.services.ai_writing import AIWritingService res = _run(AIWritingService().suggest_properties(properties=[])) assert res == {} def test_coerce_value_types(client): from app.services.ai_writing import AIWritingService svc = AIWritingService() assert svc._coerce_value("true", "checkbox") is True assert svc._coerce_value("non", "checkbox") is False assert svc._coerce_value("42", "number") == 42 assert svc._coerce_value("3.5", "number") == 3.5 assert svc._coerce_value("hello", "text") == "hello" def test_parse_json_object(client): from app.services.ai_writing import AIWritingService assert AIWritingService._parse_json_object('```json\n{"Status": "Done"}\n```') == {"Status": "Done"} assert AIWritingService._parse_json_object("not json") == {} assert AIWritingService._parse_json_object("") == {} # ── Router: /api/agent/writing ── def test_route_write(client): r = client.post("/api/agent/writing", json={"action": "write", "prompt": "Un guide"}) assert r.status_code == 200 d = r.json() assert d["ok"] is True assert d["action"] == "write" assert d["text"].strip() def test_route_summarize(client): r = client.post("/api/agent/writing", json={ "action": "summarize", "context": "# Titre\n\nUn long paragraphe explicatif sur le fonctionnement du produit."}) assert r.status_code == 200 assert r.json()["ok"] is True assert r.json()["text"].strip() def test_route_autocomplete(client): r = client.post("/api/agent/writing", json={ "action": "autocomplete", "prefix": "Une phrase assez longue pour lancer la completion"}) assert r.status_code == 200 assert r.json()["ok"] is True def test_route_translate(client): r = client.post("/api/agent/writing", json={ "action": "translate", "context": "Bonjour", "target_language": "English"}) assert r.status_code == 200 assert r.json()["ok"] is True def test_route_missing_action(client): r = client.post("/api/agent/writing", json={}) assert r.status_code == 400 def test_route_unknown_action(client): r = client.post("/api/agent/writing", json={"action": "bogus"}) assert r.status_code == 400 def test_route_write_requires_prompt(client): r = client.post("/api/agent/writing", json={"action": "write"}) assert r.status_code == 400 def test_route_write_with_title_only(client): r = client.post("/api/agent/writing", json={"action": "write", "title": "Mon titre"}) assert r.status_code == 200 assert r.json()["text"].strip() def test_route_properties_action_rejected_here(client): r = client.post("/api/agent/writing", json={ "action": "properties", "properties": [{"name": "Status", "type": "select"}]}) assert r.status_code == 400 # ── Router: /api/agent/writing/properties ── def test_route_properties(client): r = client.post("/api/agent/writing/properties", json={ "title": "Tâche importante", "content": "Il faut absolument traiter ce point avant la fin de la semaine.", "properties": [{"name": "Status", "type": "select"}, {"name": "Priority", "type": "select"}]}) assert r.status_code == 200 d = r.json() assert d["ok"] is True assert d["suggestions"]["Status"] == "To do" assert d["suggestions"]["Priority"] == "Medium" def test_route_properties_requires_properties(client): r = client.post("/api/agent/writing/properties", json={"title": "x"}) assert r.status_code == 400 def test_route_properties_empty_list(client): r = client.post("/api/agent/writing/properties", json={"properties": []}) assert r.status_code == 400 # ── Front-end wiring (templates) ── def _read_template(name: str) -> str: base = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) with open(os.path.join(base, "app", "templates", name), encoding="utf-8") as fh: return fh.read() def _read_js(name: str) -> str: """A27 : le JS de l'éditeur vit dans static/js depuis l'extraction.""" base = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) with open(os.path.join(base, "static", "js", name), encoding="utf-8") as fh: return fh.read() def test_editor_has_ai_slash_commands(client): src = _read_js("page_editor_scripts.js") for cmd in ("ai_write", "ai_summarize", "ai_translate", "ai_continue"): assert cmd in src assert "AI writing" in src assert "aiSlash" in src assert "/api/agent/writing" in src def test_editor_has_autocomplete(client): src = _read_js("page_editor_scripts.js") assert "AIAC" in src assert "autocomplete" in src assert "AIAC.schedule" in src def test_database_has_ai_properties(client): src = _read_js("database_table.js") assert "db-ai-fill-btn" in src assert "aiFillRow" in src assert "/api/agent/writing/properties" in src