feat: chat — les posts affichent le markdown rendu comme un document, code coloré #193
CI / lint (push) Successful in 2m50s
CI / security (push) Successful in 1m37s
CI / test (push) Successful in 4m34s
CI / build (push) Successful in 1m31s
CI / e2e (push) Successful in 17m36s

This commit is contained in:
2026-10-09 15:48:04 -04:00
parent 4fb7c43e06
commit f4c8504c8d
17 changed files with 308 additions and 19 deletions
+24
View File
@@ -433,6 +433,30 @@ test("#110 — notification OS quand l'app est en arrière-plan", () => {
}
});
// --- #193 : un post est rendu comme un document (markdown + code coloré) -----
test("#193 — post avec html rendu : markdown + bloc de code", () => {
const list = document.createElement("div");
renderChatMessages(list, [{
id: "md1", user: "alice", text: "**gras**\n\n```python\nprint(1)\n```", ts: 400,
html: '<p><strong>gras</strong></p><pre><code class="language-python">print(1)</code></pre>',
}], "bruno");
const body = list.querySelector("[data-id='md1'] .file-chat-body");
assert.ok(body.classList.contains("file-chat-md"), "classe md posée sur le corps");
assert.ok(body.querySelector(".md-content"), "conteneur md-content");
assert.ok(body.querySelector(".md-content strong"), "markdown rendu (strong)");
assert.ok(body.querySelector("pre code.language-python"), "bloc de code conservé");
});
test("#193 — sans html, repli texte brut + URL cliquable", () => {
const list = document.createElement("div");
renderChatMessages(list, [{ id: "md2", user: "alice", text: "voir https://ex.com/a", ts: 401 }], "bruno");
const body = list.querySelector("[data-id='md2'] .file-chat-body");
assert.ok(!body.classList.contains("file-chat-md"), "pas de rendu markdown");
assert.ok(body.querySelector("a.file-chat-link"), "URL toujours cliquable");
assert.strictEqual(body.textContent, "voir https://ex.com/a", "texte intact");
});
// Sequential execution: each case depends on panel state left by the previous.
for (const [name, fn] of cases) {
try {
+71
View File
@@ -1,6 +1,7 @@
# tests/test_file_chat.py — Chat par fichier (#169)
"""Store + REST routes for the per-file chat history."""
import json
import time
import pytest
@@ -479,3 +480,73 @@ class TestReadReceipts:
assert [e for e, _ in events] == ["chat_read"]
assert events[0][1]["user"] == "anonymous"
assert events[0][1]["vault"] == "__global__"
# ═══════════════════════════════════════════════════════════════════
# #193 — un post est rendu comme un document markdown
# ═══════════════════════════════════════════════════════════════════
class TestMarkdownRendering:
"""Le post porte son HTML (mistune + sanitizer), comme une prévisualisation
de document : le client n'a plus à réimplémenter le markdown."""
def test_message_carries_rendered_html(self, tmp_path, monkeypatch):
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
msg = _store.add_message("V", "a.md", "u", "**gras** et `code`")
assert "<strong>gras</strong>" in msg["html"]
assert "<code>code</code>" in msg["html"]
got = _store.get_messages("V", "a.md")
assert "<strong>gras</strong>" in got[0]["html"]
def test_html_is_not_persisted(self, tmp_path, monkeypatch):
"""`html` est recalculé à chaque lecture, jamais écrit dans le store."""
chat_dir = tmp_path / "chats"
monkeypatch.setattr(_store, "CHAT_DIR", chat_dir)
_store.add_message("V", "a.md", "u", "**gras**")
raw = json.loads(next(chat_dir.glob("*.json")).read_text(encoding="utf-8"))
assert "html" not in raw["messages"][0]
def test_fenced_code_block_keeps_its_language(self, tmp_path, monkeypatch):
"""La classe `language-xxx` est ce sur quoi hljs se branche (comme dans
le viewer)."""
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
msg = _store.add_message("V", "a.md", "u", "```python\nprint(1)\n```")
assert 'class="language-python"' in msg["html"]
def test_tables_and_lists_are_rendered(self, tmp_path, monkeypatch):
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
msg = _store.add_message("V", "a.md", "u", "- un\n- deux")
assert "<ul>" in msg["html"] and "<li>un</li>" in msg["html"]
def test_raw_html_is_sanitized(self, tmp_path, monkeypatch):
"""Pas de XSS par le chat : le sanitizer des documents s'applique."""
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
msg = _store.add_message("V", "a.md", "u", "<script>alert(1)</script>ok")
assert "<script" not in msg["html"]
def test_routes_return_html(self, client, test_vault_dir):
r = client.post(
"/api/file/TestVault/chat",
json={"path": "note1.md", "text": "## Titre\n\n- item"},
)
assert r.status_code == 200
html = r.json()["message"]["html"]
assert "<h2" in html and "<li>item</li>" in html
r2 = client.get("/api/file/TestVault/chat", params={"path": "note1.md"})
assert "<h2" in r2.json()["messages"][0]["html"]
def test_sse_broadcast_carries_html(self, client, test_vault_dir, monkeypatch):
"""L'écho SSE part avec le html : les autres clients n'attendent pas un
refresh pour voir le rendu."""
from backend.routers import file_chat as _router
events = []
class _Spy:
async def broadcast(self, event, data):
events.append((event, data))
monkeypatch.setattr(_router, "sse_manager", _Spy())
client.post("/api/chat", json={"text": "**fort**"})
assert events and events[0][0] == "chat_message"
assert "<strong>fort</strong>" in events[0][1]["message"]["html"]