From 2813d546a7d3d3fc523853a3c5a9540fa1b0329a Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 1 Oct 2026 20:31:05 -0400 Subject: [PATCH] feat: facette Extensions dans les facettes de recherche #157 --- .gitea/workflows/ci.yml | 2 + CHANGELOG.md | 14 ++- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- backend/search.py | 7 ++ desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md | 2 +- docs/ROADMAP.md | 3 +- docs/archive/COMPLETED_v1-v2.md | 16 +++ frontend/index.html | 4 + frontend/js/search.js | 28 ++++- frontend/locales/en.json | 1 + frontend/locales/fr.json | 1 + package.json | 2 +- tests/frontend/search-facets.test.mjs | 150 ++++++++++++++++++++++++ tests/test_search.py | 8 ++ 19 files changed, 243 insertions(+), 15 deletions(-) create mode 100644 tests/frontend/search-facets.test.mjs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 0abe10d..9a92e62 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -58,6 +58,7 @@ jobs: node ai.test.mjs node ai-sidebar.test.mjs node sidebar-filters.test.mjs + node search-facets.test.mjs node sw.test.mjs node collab.test.mjs node mobile-editor.test.mjs @@ -78,6 +79,7 @@ jobs: node ai.test.mjs node ai-sidebar.test.mjs node sidebar-filters.test.mjs + node search-facets.test.mjs node sw.test.mjs node collab.test.mjs node mobile-editor.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 003a8fb..a3aefe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.45.2**. +> [Unreleased](#unreleased). La dernière version livrée est **2.46.0**. --- @@ -14,6 +14,18 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.46.0] — 2026-10-01 + +### Ajouté + +- **#157 — facette « Extensions » dans les facettes des résultats de recherche.** Le panneau de + facettes de la page de résultats (Vaults · Tags) gagne un troisième groupe qui liste les + extensions de fichiers présents dans les résultats (`.md`, `.xlsx`, `.pdf`, …) avec leurs + compteurs ; cliquer une extension ajoute l'opérateur `ext:` à la requête (en remplaçant un + `ext:` déjà actif). Le backend calcule `facets.extensions` dans `backend/search.py`. + +--- + ## [2.45.2] — 2026-10-01 --- diff --git a/README.fr.md b/README.fr.md index f69d982..e1122e5 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.45.2-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.46.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.45.2). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.46.0). --- -*Projet : ObsiGate | Version : 2.45.2 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.46.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index bc4a803..46f8232 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.45.2-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.46.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.45.2). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.46.0). --- -*Project: ObsiGate | Version: 2.45.2 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.46.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index f607540..cc87583 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.45.2 +2.46.0 diff --git a/backend/search.py b/backend/search.py index 587c56f..321c5ad 100644 --- a/backend/search.py +++ b/backend/search.py @@ -1327,6 +1327,7 @@ def advanced_search( scored_results: list[tuple[float, dict[str, Any]]] = [] facet_tags: dict[str, int] = defaultdict(int) facet_vaults: dict[str, int] = defaultdict(int) + facet_extensions: dict[str, int] = defaultdict(int) # Pre-compute prefix expansions once per term (avoid repeated binary search) prefix_expansions: dict[str, list[str]] = {} @@ -1426,6 +1427,11 @@ def advanced_search( facet_vaults[result["vault"]] = facet_vaults.get(result["vault"], 0) + 1 for tag in result.get("tags", []): facet_tags[tag] = facet_tags.get(tag, 0) + 1 + # Extension normalised without leading dot ("md", not ".md") so it can be + # fed back directly as the `ext:` query operator. + ext = str(result.get("extension") or "").lower().lstrip(".") + if ext: + facet_extensions[ext] = facet_extensions.get(ext, 0) + 1 total = len(scored_results) page = scored_results[offset: offset + limit] @@ -1439,6 +1445,7 @@ def advanced_search( "facets": { "tags": dict(sorted(facet_tags.items(), key=lambda x: -x[1])[:20]), "vaults": dict(sorted(facet_vaults.items(), key=lambda x: -x[1])), + "extensions": dict(sorted(facet_extensions.items(), key=lambda x: -x[1])), }, "query_time_ms": elapsed_ms, "semantic_available": semantic_available, diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 0526dd9..da80d19 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.45.2" +version = "2.46.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index c7edbe1..92c5985 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.45.2" +version = "2.46.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index f826c66..77613db 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.45.2", + "version": "2.46.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md b/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md index 816a1bc..d7b07b8 100644 --- a/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md +++ b/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md @@ -19,7 +19,7 @@ avec : - **Normalisation des accents** — `resume` trouve `résumé`, `elephant` trouve `éléphant`. - **Stemming français** — les variantes des mots sont rapprochées. - **Snippets surlignés** — les termes trouvés sont mis en `` dans l'extrait. -- **Facettes** — compteurs par vault et par tag sur les résultats. +- **Facettes** — compteurs par vault, par tag et par extension sur les résultats. - **Pagination** — 50 résultats par page. - **Tri** — par pertinence (TF-IDF) ou par date de modification. - **Chips de filtres** — les filtres actifs apparaissent sous forme de puces retirables. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6a87497..62e01b0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.45.2 | **Dernière mise à jour :** 2026-10-01 +> **Version :** 2.46.0 | **Dernière mise à jour :** 2026-10-01 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** @@ -304,6 +304,7 @@ | 115 | Viewer — barre d'outils de lecture épinglée au défilement | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) | | 117 | Configuration — avatars prédéfinis dans le profil utilisateur (12 images) | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) | | 85 | Refonte architecturale — découpage du monolithe (14 routers, `main.py` 4 827 → ~750 lignes), stores JSON verrouillés, rate-limit SQLite optionnel | 2.27.2→2.27.13 | [features/archi-refonte-85.md](./features/archi-refonte-85.md) | +| 157 | Recherche — facette « Extensions » dans les résultats (3ᵉ filtre avec Vaults et Tags) | 2.46.0 | [archive](./archive/COMPLETED_v1-v2.md) | --- diff --git a/docs/archive/COMPLETED_v1-v2.md b/docs/archive/COMPLETED_v1-v2.md index efa7531..bfe2c33 100644 --- a/docs/archive/COMPLETED_v1-v2.md +++ b/docs/archive/COMPLETED_v1-v2.md @@ -419,6 +419,22 @@ première modification et téléchargement du fichier d'origine. --- +## #157 — Recherche : facette « Extensions » ✅ TERMINÉ + +Le panneau de facettes de la page de résultats de recherche affiche désormais **trois** groupes : +**Vaults**, **Tags** et **Extensions**. Le troisième liste les extensions de fichiers présents +dans le résultat courant (`.md`, `.xlsx`, `.pdf`, …) avec leur compteur ; un clic ajoute +l'opérateur `ext:` à la requête (un `ext:` déjà actif est remplacé, jamais dupliqué). + +| Aspect | Détail | +|---|---| +| Backend | `backend/search.py::advanced_search` — `facets.extensions` : compteurs par extension normalisée (sans point, en minuscules), triés par fréquence | +| Frontend | `frontend/js/search.js::renderAdvancedSearchResults` — groupe `[data-facet="extensions"]`, items montés en `textContent` (les suffixes viennent de noms de fichiers non fiables) | +| i18n / aide | `help.desc_facet_extensions` (FR/EN), guide `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md` | +| Tests | `tests/test_search.py::test_facets` (clé `extensions`), `tests/frontend/search-facets.test.mjs` (4 tests JSDOM, ajoutés au CI) | + +--- + ## Grosses fonctionnalités — fiches dédiées | # | Feature | Version | Fiche | diff --git a/frontend/index.html b/frontend/index.html index 61dd913..6d37fed 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3694,6 +3694,10 @@ Facettes vaults: Compteurs par vault +
  • + Facettes extensions: + Compteurs par extension de fichier dans les résultats +
  • Tri par pertinence : Score TF-IDF (défaut)
  • diff --git a/frontend/js/search.js b/frontend/js/search.js index f263584..948e8a9 100644 --- a/frontend/js/search.js +++ b/frontend/js/search.js @@ -1034,7 +1034,7 @@ export function renderAdvancedSearchResults(data, query, tagFilter) { } // Facets panel - if (data.facets && (Object.keys(data.facets.tags || {}).length > 0 || Object.keys(data.facets.vaults || {}).length > 0)) { + if (data.facets && (Object.keys(data.facets.tags || {}).length > 0 || Object.keys(data.facets.vaults || {}).length > 0 || Object.keys(data.facets.extensions || {}).length > 0)) { const facetsDiv = el("div", { class: "search-facets" }); // Vault facets @@ -1078,6 +1078,32 @@ export function renderAdvancedSearchResults(data, query, tagFilter) { facetsDiv.appendChild(group); } + // Extension facets + const extFacets = data.facets.extensions || {}; + if (Object.keys(extFacets).length > 0) { + const group = el("div", { class: "search-facets__group", "data-facet": "extensions" }); + const label = el("span", { class: "search-facets__label" }); + label.textContent = "Extensions"; + group.appendChild(label); + for (const [extName, count] of Object.entries(extFacets)) { + const item = el("span", { class: "search-facets__item" }); + // textContent: extension suffixes come from filenames (untrusted), never innerHTML + item.appendChild(document.createTextNode(`.${extName} `)); + const facetCount = el("span", { class: "facet-count" }); + facetCount.textContent = String(count); + item.appendChild(facetCount); + item.addEventListener("click", () => { + const input = document.getElementById("search-input"); + // Replace any existing ext: operator, then trigger a new search + const current = input.value.replace(/ext:\S+\s*/gi, "").trim(); + input.value = current + " ext:" + extName; + _triggerAdvancedSearch(input.value); + }); + group.appendChild(item); + } + facetsDiv.appendChild(group); + } + area.appendChild(facetsDiv); } diff --git a/frontend/locales/en.json b/frontend/locales/en.json index eb39007..9d9a36b 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1101,6 +1101,7 @@ "help.desc_f952db17": "Refresh tokens : Cookies HttpOnly avec rotation automatique", "help.desc_fa126609": "Converts the document structure into an Obsidian Canvas (visual cards).", "help.desc_facet_vaults": ": Counters by vault", + "help.desc_facet_extensions": ": Counters by file extension in the results", "help.desc_fc9391f3": "ObsiGate sends an HTTP POST to your webhook URL with event details.", "help.desc_fdeaf803": ": Each file type has its icon (markdown, code, etc.)", "help.desc_file_suggest": ": File title suggestions", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index d3b9add..ed1f03f 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1101,6 +1101,7 @@ "help.desc_f952db17": "Refresh tokens : Cookies\n HttpOnly avec rotation automatique", "help.desc_fa126609": "🔷\n Convert to canvas :\n Convertit en diagramme Mermaid.js", "help.desc_facet_vaults": ": Compteurs par vault", + "help.desc_facet_extensions": ": Compteurs par extension de fichier dans les résultats", "help.desc_fc9391f3": "# Exemple avec curl + jq dans un script bash\nWEBHOOK_URL=\"https://discord.com/api/webhooks/...\"\nOBSIGATE_SECRET=\"mon-secret\"\n\n# Lire le payload depuis stdin\nread -r payload\nevent=$(echo \"$payload\" | jq -r '.event')\npath=$(echo \"$payload\" | jq -r '.data.path')\n\ncurl -H \"Content-Type: application/json\" -d \"{\n \\\"content\\\": \\\"📝 **$event** — $path\\\"\n}\" \"$WEBHOOK_URL\"\n\n Tester avec curl\n # Simuler ce qu'ObsiGate envoie (pour tester votre endpoint)\ncurl -X POST https://votre-serveur.com/webhook \\\n -H \"Content-Type: application/json\" \\\n -H \"X-ObsiGate-Event: file_created\" \\\n -H \"X-ObsiGate-Signature: sha256=$(echo -n '{\"event\":\"file_created\",...}' | openssl dgst -sha256 -hmac 'mon-secret' | cut -d' ' -f2)\" \\\n -d '{\"event\":\"file_created\",\"timestamp\":\"2026-06-03T14:30:00Z\",\"data\":{\"vault\":\"IT\",\"path\":\"notes/test.md\"}}'\n\n Limitations actuelles\n \n \n Timeout de 5 secondes par\n requête — les webhooks sont \"fire and\n forget\" (pas de retry)\n \n \n Les modifications détectées par le watcher\n filesystem (watchdog)\n ne déclenchent pas de\n webhooks — seules les opérations via l'API\n REST sont prises en compte\n \n \n file_modified n'est pas encore\n dispatché (réservé pour version future)\n \n Pas de retry automatique en cas d'échec\n \n Pas de filtrage par vault — un webhook\n écoute toutes les vaults\n \n \n \n \n 💾 Sauvegardes et Audits\n\n Backup automatique\n \n ObsiGate crée automatiquement des sauvegardes\n avant chaque modification destructive.", "help.desc_fdeaf803": ": Chaque type de\n fichier a son icône (markdown, code, etc.)", "help.desc_file_suggest": ": Suggestions de titres de fichiers", diff --git a/package.json b/package.json index 380f4ce..c0760fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.45.2", + "version": "2.46.0", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": { diff --git a/tests/frontend/search-facets.test.mjs b/tests/frontend/search-facets.test.mjs new file mode 100644 index 0000000..497f6ae --- /dev/null +++ b/tests/frontend/search-facets.test.mjs @@ -0,0 +1,150 @@ +#!/usr/bin/env node +/** + * ObsiGate - JSDOM tests for the search results facets panel (#157): + * vault, tag and file-extension facets rendered by + * `renderAdvancedSearchResults()` (frontend/js/search.js). + * + * Covers: + * - the three facet groups are rendered (Vaults / Tags / Extensions) + * - the extensions facet appears even when tags and vaults are empty + * - clicking an extension replaces any `ext:` operator in the search + * input and triggers a new search + * + * Usage: node tests/frontend/search-facets.test.mjs + */ + +import { strict as assert } from "node:assert"; +import { JSDOM } from "jsdom"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import path from "node:path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const JS_DIR = path.resolve(__dirname, "..", "..", "frontend", "js"); + +const dom = new JSDOM( + ` + + + + + +
    +`, + { url: "http://localhost/", pretendToBeVisual: true }, +); + +const w = dom.window; +globalThis.window = w; +globalThis.document = w.document; +globalThis.HTMLElement = w.HTMLElement; +globalThis.Element = w.Element; +globalThis.Node = w.Node; +globalThis.Event = w.Event; +globalThis.CustomEvent = w.CustomEvent; +globalThis.KeyboardEvent = w.KeyboardEvent; +globalThis.localStorage = w.localStorage; +globalThis.sessionStorage = w.sessionStorage; +Object.defineProperty(globalThis, "navigator", { + value: w.navigator, + configurable: true, + writable: true, +}); +globalThis.MutationObserver = w.MutationObserver; +globalThis.getComputedStyle = w.getComputedStyle.bind(w); +globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0); +globalThis.cancelAnimationFrame = (id) => clearTimeout(id); + +let testCount = 0; +let passCount = 0; + +async function test(name, fn) { + testCount++; + try { + await fn(); + passCount++; + console.log(` ✓ ${name}`); + } catch (err) { + console.error(` ✗ ${name}\n ${err.message}`); + process.exitCode = 1; + } +} + +// Backend payload shape for /api/search/advanced (facets only matter here). +const PAYLOAD = { + results: [], + total: 0, + offset: 0, + limit: 50, + query_time_ms: 1, + facets: { + vaults: { IT: 4, Recettes: 2 }, + tags: { linux: 3 }, + extensions: { md: 5, pdf: 2, xlsx: 1 }, + }, +}; + +globalThis.fetch = async () => ({ + ok: true, + status: 200, + json: async () => PAYLOAD, +}); + +const { renderAdvancedSearchResults } = await import( + pathToFileURL(path.join(JS_DIR, "search.js")).href +); + +const facetGroups = () => + Array.from(document.querySelectorAll(".search-facets__group")); +const extGroup = () => + document.querySelector('.search-facets__group[data-facet="extensions"]'); +const extItems = () => + Array.from(extGroup()?.querySelectorAll(".search-facets__item") || []); + +console.log("Search facets (#157) — Vaults / Tags / Extensions"); + +await test("renders the three facet groups", async () => { + renderAdvancedSearchResults(PAYLOAD, "python", null); + assert.equal(facetGroups().length, 3, "vault, tag and extension groups"); + const labels = facetGroups().map((g) => + g.querySelector(".search-facets__label").textContent, + ); + assert.deepEqual(labels, ["Vaults", "Tags", "Extensions"]); +}); + +await test("lists extensions with their counts", async () => { + const texts = extItems().map((i) => i.textContent.trim()); + assert.deepEqual(texts, [".md 5", ".pdf 2", ".xlsx 1"]); + assert.equal( + extItems()[0].querySelector(".facet-count").textContent, + "5", + "count lives in .facet-count", + ); +}); + +await test("extension facet shows even with empty tags and vaults", async () => { + renderAdvancedSearchResults( + { ...PAYLOAD, facets: { vaults: {}, tags: {}, extensions: { md: 1 } } }, + "", + null, + ); + assert.ok(extGroup(), "extensions group rendered alone"); + assert.equal(extItems().length, 1); +}); + +await test("clicking an extension adds the ext: operator", async () => { + document.getElementById("search-input").value = "python ext:md"; + renderAdvancedSearchResults(PAYLOAD, "python", null); + extItems()[1].click(); // .pdf + assert.equal( + document.getElementById("search-input").value, + "python ext:pdf", + "existing ext: operator replaced, not duplicated", + ); +}); + +// ── Summary ── +console.log(`\n${passCount}/${testCount} tests passed`); +if (passCount !== testCount) { + process.exit(1); +} diff --git a/tests/test_search.py b/tests/test_search.py index 8bf563d..24da3a3 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -225,6 +225,14 @@ class TestAdvancedSearchAPI: resp = client.get("/api/advanced-search?q=python&vault=all") data = self._check(resp) assert "facets" in data + # #157 — extension facet: keys are query-ready (no leading dot) and, + # when there are results, at least one extension is counted. + exts = data["facets"]["extensions"] + assert isinstance(exts, dict) + assert all(not e.startswith(".") for e in exts) + if data["total"] > 0: + assert len(exts) >= 1 + assert sum(exts.values()) <= data["total"] def test_empty_query(self, client): resp = client.get("/api/advanced-search?q=")