- `_page_editor_scripts.html` : le gros bloc interpolé (2 516 L) part vers
`static/js/page_editor_scripts.js` — recette « config JSON » : les 8
interpolations Jinja lisent `PD = JSON.parse(#page-data)`, bloc JSON qui
EXISTAIT DÉJÀ juste avant le script (même ordre d'exécution), garde
`__fdEditorScriptsLoaded` préservée, node --check vert.
- Route `view_page_root` : page_data enrichi de updated_at, created_at,
user_id, is_shared (dérivé HOISTÉ : une seule expression sert le ctx ET le
JSON) et clip_icon (macro fd_icon rendue côté serveur). workspace_key reste
vide comme avant (jamais défini dans ce ctx → parité stricte).
8 tests adaptés à l'extraction (ils lisaient le template SOURCE) :
- test_ai_writing ×2 (+ helper _read_js), test_pwa_offline,
test_v511 front_end_wired, test_v55 ×3 → lisent le JS extrait
- test_page_editor_renders_page_is_shared → parsing du JSON #page-data
(`is_shared is True`) — la valeur sert toujours à la page
Cumul A27 : 6 759 L extraites (13 904 → 7 145 inline). Reste : local_workspace
2 031, base 1 523 (structurel {% for %}/{% block %}), database_table 1 323,
settings 1 093, realtime 531, board 146 ≈ 6 653 L + 120 warnings eslint.
suite **1089/1089** · ruff OK · docs à jour
301 lines
10 KiB
Python
301 lines
10 KiB
Python
"""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_template("_database_table_scripts.html")
|
|
assert "db-ai-fill-btn" in src
|
|
assert "aiFillRow" in src
|
|
assert "/api/agent/writing/properties" in src
|