"""Tests for Excalidraw file support in ObsiGate.""" import json EXCALIDRAW_SKELETON = { "type": "excalidraw", "version": 2, "elements": [], "appState": {"viewBackgroundColor": "#ffffff"}, "files": {}, } EXCALIDRAW_WITH_CONTENT = { "type": "excalidraw", "version": 2, "elements": [ { "id": "rect-1", "type": "rectangle", "x": 100, "y": 200, "width": 300, "height": 150, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffec99", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "roundness": {"type": 3}, "boundElements": None, "updated": 1, "link": None, "locked": False, }, { "id": "text-1", "type": "text", "x": 150, "y": 260, "width": 200, "height": 30, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "roundness": None, "boundElements": None, "updated": 1, "link": None, "locked": False, "text": "Hello Excalidraw", "fontSize": 20, "fontFamily": 1, "textAlign": "center", "verticalAlign": "middle", "containerId": "rect-1", "originalText": "Hello Excalidraw", "autoResize": True, "lineHeight": 1.25, }, ], "appState": {"viewBackgroundColor": "#ffffff", "name": "My Diagram"}, "files": {}, } # ████████████████████████████████████████████████████████████████████████ # B5 — Indexation du contenu texte Excalidraw (recherche TF-IDF) # ████████████████████████████████████████████████████████████████████████ # Fixture lz-string "compressToBase64" générée par le vrai paquet # lz-string@1.5.0 (JS) en 2026-09-09, validée byte-pour-byte contre le port # Python de `_decompress_excalidraw`. Contient du texte accentué. LZ_COMPRESSED_DIAGRAM = ( "N4IgLgngDgpiBcIYA8DGBDANgSwCYCd0B3EAGhADcZ8BnbAewDsEAmcmTGAWxkbBoQBtUHgQh0" "ZcNDiIwKMJLnIFiABIdM9AAQBRNFjyES5evmwBzbIywAVeWPWZNu/TgLFJyBAEYADOQhWXwBf" "UhFcMQAjRWkxJQVyeLEAJQBLmgBXLlStOVQAC0ZsAEcMmC0AMi1GejkBEzNLa0w7ZRT0rJy8" "wpKyyuramHqQL3gAZn8QQPgAFhCwkFFEVBjYMXwYVDB0RnNOSUx0SI4xACF6ADu5LWwuKF" "NtvjhyUYBWAIRX4IBdcnQoKAAZW2cgQoGsPDEABEhnRmKEQAAzbCcATwYDBYJAA" ) _RAW_EXCALIDRAW_MD = """--- excalidraw-plugin: parsed --- ```compressed-json {comp} ``` Ce texte hors bloc ne doit pas polluer l'index (on extrait la scène uniquement). """ def _make_excalidraw_md(compressed: str) -> str: return _RAW_EXCALIDRAW_MD.format(comp=compressed) class TestExcalidrawDetection: """Test that the /api/file endpoint correctly detects .excalidraw files.""" VAULT = "TestVault" def test_valid_excalidraw_file(self, client): """A valid .excalidraw file should return is_excalidraw: true.""" path = "diagram.excalidraw" resp = client.post( f"/api/file/{self.VAULT}", json={"path": path, "content": json.dumps(EXCALIDRAW_WITH_CONTENT)}, ) assert resp.status_code == 200, resp.text resp = client.get(f"/api/file/{self.VAULT}", params={"path": path}) assert resp.status_code == 200, resp.text data = resp.json() assert data["is_excalidraw"] is True assert "excalidraw_data" in data assert len(data["excalidraw_data"]["elements"]) == 2 assert data["excalidraw_data"]["elements"][0]["type"] == "rectangle" assert data["title"] == "My Diagram" def test_empty_excalidraw_creation(self, client): """Creating a .excalidraw with empty content injects skeleton JSON.""" path = "empty.excalidraw" resp = client.post( f"/api/file/{self.VAULT}", json={"path": path, "content": ""}, ) assert resp.status_code == 200, resp.text resp = client.get(f"/api/file/{self.VAULT}", params={"path": path}) assert resp.status_code == 200, resp.text data = resp.json() assert data["is_excalidraw"] is True assert data["excalidraw_data"]["elements"] == [] assert data["excalidraw_data"]["appState"]["viewBackgroundColor"] == "#ffffff" def test_invalid_json_excalidraw_fallback(self, client): """A .excalidraw with invalid JSON should show as text, not Excalidraw.""" path = "corrupt.excalidraw" resp = client.post( f"/api/file/{self.VAULT}", json={"path": path, "content": "this is not valid json!!!"}, ) assert resp.status_code == 200, resp.text resp = client.get(f"/api/file/{self.VAULT}", params={"path": path}) assert resp.status_code == 200, resp.text data = resp.json() assert not data.get("is_excalidraw") def test_json_without_excalidraw_type(self, client): """A .excalidraw with valid JSON but wrong type falls through.""" path = "not-excalidraw.excalidraw" resp = client.post( f"/api/file/{self.VAULT}", json={"path": path, "content": '{"type":"something-else","data":[1,2,3]}'}, ) assert resp.status_code == 200, resp.text resp = client.get(f"/api/file/{self.VAULT}", params={"path": path}) assert resp.status_code == 200, resp.text data = resp.json() assert not data.get("is_excalidraw") def test_excalidraw_in_tree(self, client): """A .excalidraw file appears in the file tree.""" path = "tree-diagram.excalidraw" resp = client.post( f"/api/file/{self.VAULT}", json={"path": path, "content": json.dumps(EXCALIDRAW_SKELETON)}, ) assert resp.status_code == 200, resp.text resp = client.get(f"/api/browse/{self.VAULT}", params={"path": ""}) assert resp.status_code == 200, resp.text items = resp.json()["items"] names = [i["name"] for i in items] assert "tree-diagram.excalidraw" in names, f"Not found in: {names}" def test_excalidraw_save_roundtrip(self, client): """Create, edit (PUT), and re-read an .excalidraw file.""" path = "roundtrip.excalidraw" client.post( f"/api/file/{self.VAULT}", json={"path": path, "content": ""}, ) modified = dict(EXCALIDRAW_SKELETON) modified["elements"] = [{ "id": "ellipse-1", "type": "ellipse", "x": 50, "y": 50, "width": 100, "height": 80, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#a5d8ff", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "roundness": {"type": 2}, "boundElements": None, "updated": 1, "link": None, "locked": False, }] resp = client.put( f"/api/file/{self.VAULT}/save", params={"path": path, "backup": "false"}, json={"content": json.dumps(modified)}, ) assert resp.status_code == 200, resp.text resp = client.get(f"/api/file/{self.VAULT}", params={"path": path}) assert resp.status_code == 200, resp.text data = resp.json() assert data["is_excalidraw"] is True assert len(data["excalidraw_data"]["elements"]) == 1 assert data["excalidraw_data"]["elements"][0]["type"] == "ellipse" class TestExcalidrawIndexableTextB5: """B5 — Indexation du texte des éléments (recherche TF-IDF).""" def test_pure_json_extracts_element_text(self): from backend.indexer import extract_excalidraw_indexable out = extract_excalidraw_indexable(json.dumps(EXCALIDRAW_WITH_CONTENT)) assert "Hello Excalidraw" in out def test_pure_json_skeleton_has_no_text(self): from backend.indexer import extract_excalidraw_indexable out = extract_excalidraw_indexable(json.dumps(EXCALIDRAW_SKELETON)) assert out.strip() == "" def test_pure_json_binds_label_and_title(self): from backend.indexer import extract_excalidraw_indexable payload = { "type": "excalidraw", "version": 2, "elements": [ {"id": "r", "type": "rectangle", "label": "Boite bleue"}, {"id": "t", "type": "text", "text": "texte simple", "originalText": "texte simple"}, ], "appState": {}, "files": {}, } out = extract_excalidraw_indexable(json.dumps(payload)) assert "Boite bleue" in out assert "texte simple" in out def test_lz_decompress_matches_js_fixture(self): """Le port Python décode un payload lz-string produit par le vrai JS.""" from backend.indexer import _decompress_excalidraw data = _decompress_excalidraw(LZ_COMPRESSED_DIAGRAM) assert data is not None assert data["type"] == "excalidraw" elements = data["elements"] texts = {e.get("text") or e.get("label") for e in elements if isinstance(e, dict)} assert "Hello Excalidraw" in texts assert "Résumé technique & notes" in texts assert "Boîte importante" in texts def test_lz_decompress_rejects_garbage(self): from backend.indexer import _decompress_excalidraw assert _decompress_excalidraw("") is None assert _decompress_excalidraw("@@@not-lz@@@") is None assert _decompress_excalidraw("!!!") is None def test_excalidraw_md_compressed_extracts_text(self): """.excalidraw.md (format plugin Obsidian) → texte indexé.""" from backend.indexer import extract_excalidraw_indexable out = extract_excalidraw_indexable(_make_excalidraw_md(LZ_COMPRESSED_DIAGRAM)) assert "Hello Excalidraw" in out assert "Boîte importante" in out def test_excalidraw_md_missing_block_returns_empty(self): from backend.indexer import extract_excalidraw_indexable out = extract_excalidraw_indexable("---\nexcalidraw-plugin: parsed\n---\n(pas de bloc)") assert out.strip() == ""