diff --git a/package.json b/package.json index c84c2b6..b7a502a 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "tauri": "tauri" + "tauri": "tauri", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@tanstack/react-query": "^5.99.2", @@ -22,12 +24,18 @@ "devDependencies": { "@tailwindcss/vite": "^4.2.4", "@tauri-apps/cli": "^2", + "@testing-library/jest-dom": "^6.0.0", + "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.0.0", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^3.0.0", "esbuild": "^0.28.0", + "jsdom": "^26.0.0", "tailwindcss": "^4.2.4", "typescript": "~5.8.3", - "vite": "^8.0.9" + "vite": "^8.0.9", + "vitest": "^3.0.0" } } diff --git a/src-tauri/.claude/agent-memory/tauri-rust-architect/MEMORY.md b/src-tauri/.claude/agent-memory/tauri-rust-architect/MEMORY.md new file mode 100644 index 0000000..3224796 --- /dev/null +++ b/src-tauri/.claude/agent-memory/tauri-rust-architect/MEMORY.md @@ -0,0 +1,4 @@ +# Memory Index + +- [TougliGui DB Schema](project_db_schema.md) — 7 tables SQLite, conventions migrate() idempotente, structure quest_previews +- [Patterns Rust TougliGui](project_rust_patterns.md) — DbState/Mutex, Result, placeholders SQL positionnels, pattern test_db() in-memory diff --git a/src-tauri/.claude/agent-memory/tauri-rust-architect/project_db_schema.md b/src-tauri/.claude/agent-memory/tauri-rust-architect/project_db_schema.md new file mode 100644 index 0000000..0ba6574 --- /dev/null +++ b/src-tauri/.claude/agent-memory/tauri-rust-architect/project_db_schema.md @@ -0,0 +1,18 @@ +--- +name: TougliGui DB Schema +description: Tables SQLite créées par db::migrate() et conventions de la couche base de données +type: project +--- + +La fonction `db::migrate()` crée 7 tables via `execute_batch` avec `IF NOT EXISTS` (donc idempotente) : + +- `profiles` (id TEXT PK, name TEXT UNIQUE, created_at TEXT) +- `guides` (gid TEXT PK, name TEXT, data TEXT, last_synced_at TEXT) +- `quest_completions` (profile_id, quest_name — PK composite, FK → profiles ON DELETE CASCADE) +- `settings` (key TEXT PK, value TEXT) +- `quest_step_progress` (profile_id, quest_name, step_index — PK composite, FK → profiles) +- `resource_inventory` (profile_id, resource_name — PK composite, FK → profiles) +- `quest_previews` (quest_url TEXT PK, indicators_json TEXT, cached_at TEXT DEFAULT datetime('now')) + +**Why:** Connaissance nécessaire pour écrire les tests et vérifier les migrations. +**How to apply:** Toute modification de schéma doit passer par `migrate()` avec `IF NOT EXISTS` ou une migration additive. diff --git a/src-tauri/.claude/agent-memory/tauri-rust-architect/project_rust_patterns.md b/src-tauri/.claude/agent-memory/tauri-rust-architect/project_rust_patterns.md new file mode 100644 index 0000000..f90a51a --- /dev/null +++ b/src-tauri/.claude/agent-memory/tauri-rust-architect/project_rust_patterns.md @@ -0,0 +1,40 @@ +--- +name: Patterns Rust observés dans TougliGui +description: Conventions de code Rust/Tauri utilisées dans le projet (state, erreurs, SQL, modules) +type: project +--- + +## State Tauri +`DbState(pub Mutex)` dans `commands.rs` — une seule connexion SQLite partagée via Mutex. + +## Propagation d'erreurs +Les commandes Tauri retournent `Result` : `.map_err(|e| e.to_string())`. Pas de type d'erreur custom. + +## Paramètres SQL positionnels +rusqlite utilise `?1`, `?2`... (positionnels nommés) et non `?` (positionnels ordinals). Les fonctions `get_cached_previews` et `get_cached_urls` construisent dynamiquement les placeholders avec `format!("?{}", i)` pour les `IN (...)` variadic. + +## Sérialisation JSON en DB +`quest_previews.indicators_json` stocke un `Vec` sérialisé via `serde_json`. Désérialisé au retour avec `serde_json::from_str`. + +## Structure CombatIndicator (parser.rs) +```rust +pub struct CombatIndicator { + pub combat_type: String, + pub count: String, + #[serde(default)] pub label: Option, + #[serde(default)] pub evitable: bool, +} +``` + +## Pattern tests unitaires DB +```rust +fn test_db() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + crate::db::migrate(&conn).unwrap(); + conn +} +``` +Chaque test crée sa propre DB in-memory → isolation totale, pas de cleanup nécessaire. + +**Why:** Conventions observées directement dans le code source. +**How to apply:** Respecter ces patterns dans tous les ajouts futurs pour maintenir la cohérence. diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index ff062e9..cee0532 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -351,3 +351,184 @@ pub fn set_setting(conn: &Connection, key: &str, value: &str) -> Result<()> { )?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::CombatIndicator; + + fn test_db() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + migrate(&conn).unwrap(); + conn + } + + fn make_indicator(t: &str, c: &str) -> CombatIndicator { + CombatIndicator { + combat_type: t.to_string(), + count: c.to_string(), + label: None, + evitable: false, + } + } + + // ── migrate ────────────────────────────────────────────────────────────── + + #[test] + fn test_migrate_creates_all_tables() { + let conn = test_db(); + let expected_tables = [ + "profiles", + "guides", + "quest_completions", + "settings", + "quest_step_progress", + "resource_inventory", + "quest_previews", + ]; + for table in &expected_tables { + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1", + params![table], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 1, "Table '{}' manquante après migration", table); + } + } + + #[test] + fn test_migrate_idempotent() { + let conn = test_db(); + // Un second appel ne doit pas paniquer ni retourner d'erreur (IF NOT EXISTS) + migrate(&conn).expect("Le second appel à migrate() ne doit pas échouer"); + } + + // ── settings ───────────────────────────────────────────────────────────── + + #[test] + fn test_set_and_get_setting_roundtrip() { + let conn = test_db(); + set_setting(&conn, "theme", "dark").unwrap(); + let value = get_setting(&conn, "theme"); + assert_eq!(value, Some("dark".to_string())); + } + + #[test] + fn test_get_setting_missing_key_returns_none() { + let conn = test_db(); + let value = get_setting(&conn, "cle_inexistante"); + assert!(value.is_none()); + } + + #[test] + fn test_set_setting_overwrites_existing_value() { + let conn = test_db(); + set_setting(&conn, "theme", "light").unwrap(); + set_setting(&conn, "theme", "dark").unwrap(); + let value = get_setting(&conn, "theme"); + assert_eq!(value, Some("dark".to_string())); + } + + // ── upsert_preview / get_cached_previews ───────────────────────────────── + + #[test] + fn test_upsert_and_get_cached_previews_roundtrip() { + let conn = test_db(); + let url = "https://example.com/quete/test".to_string(); + let indicators = vec![ + make_indicator("Monstre", "3"), + make_indicator("Boss", "1"), + ]; + + upsert_preview(&conn, &url, &indicators).unwrap(); + + let result = get_cached_previews(&conn, &[url.clone()]); + assert!(result.contains_key(&url), "L'URL doit être présente dans le résultat"); + + let retrieved = &result[&url]; + assert_eq!(retrieved.len(), 2); + assert_eq!(retrieved[0].combat_type, "Monstre"); + assert_eq!(retrieved[0].count, "3"); + assert_eq!(retrieved[1].combat_type, "Boss"); + assert_eq!(retrieved[1].count, "1"); + } + + #[test] + fn test_upsert_preview_idempotent() { + let conn = test_db(); + let url = "https://example.com/quete/idempotent".to_string(); + let indicators_v1 = vec![make_indicator("Monstre", "2")]; + let indicators_v2 = vec![make_indicator("Boss", "5")]; + + upsert_preview(&conn, &url, &indicators_v1).unwrap(); + // Deuxième upsert sur la même URL : pas d'erreur de contrainte UNIQUE + upsert_preview(&conn, &url, &indicators_v2).unwrap(); + + let result = get_cached_previews(&conn, &[url.clone()]); + let retrieved = &result[&url]; + // Seul le dernier upsert doit être présent + assert_eq!(retrieved.len(), 1); + assert_eq!(retrieved[0].combat_type, "Boss"); + assert_eq!(retrieved[0].count, "5"); + } + + // ── get_cached_previews — cas limites ──────────────────────────────────── + + #[test] + fn test_get_cached_previews_partial_hit() { + let conn = test_db(); + let url_cached = "https://example.com/quete/en-cache".to_string(); + let url_missing = "https://example.com/quete/absente".to_string(); + + upsert_preview(&conn, &url_cached, &[make_indicator("Monstre", "1")]).unwrap(); + + let result = get_cached_previews(&conn, &[url_cached.clone(), url_missing.clone()]); + assert!(result.contains_key(&url_cached), "L'URL en cache doit être retournée"); + assert!(!result.contains_key(&url_missing), "L'URL absente ne doit pas figurer dans le résultat"); + assert_eq!(result.len(), 1); + } + + #[test] + fn test_get_cached_previews_empty_input() { + let conn = test_db(); + let result = get_cached_previews(&conn, &[]); + assert!(result.is_empty(), "Une liste vide d'URLs doit retourner un HashMap vide"); + } + + // ── get_cached_urls ─────────────────────────────────────────────────────── + + #[test] + fn test_get_cached_urls_returns_only_cached() { + let conn = test_db(); + let cached: Vec = (1..=3) + .map(|i| format!("https://example.com/quete/{}", i)) + .collect(); + let extra: Vec = (4..=5) + .map(|i| format!("https://example.com/quete/{}", i)) + .collect(); + + for url in &cached { + upsert_preview(&conn, url, &[make_indicator("Monstre", "1")]).unwrap(); + } + + let all_urls: Vec = cached.iter().chain(extra.iter()).cloned().collect(); + let result = get_cached_urls(&conn, &all_urls); + + assert_eq!(result.len(), 3, "Seules les 3 URLs en cache doivent être retournées"); + for url in &cached { + assert!(result.contains(url), "URL '{}' attendue dans le HashSet", url); + } + for url in &extra { + assert!(!result.contains(url), "URL '{}' ne doit pas être dans le HashSet", url); + } + } + + #[test] + fn test_get_cached_urls_empty_input() { + let conn = test_db(); + let result = get_cached_urls(&conn, &[]); + assert!(result.is_empty(), "Une liste vide doit retourner un HashSet vide"); + } +} diff --git a/src-tauri/src/parser.rs b/src-tauri/src/parser.rs index fdbfcb9..7299213 100644 --- a/src-tauri/src/parser.rs +++ b/src-tauri/src/parser.rs @@ -46,7 +46,7 @@ pub struct QuestItem { pub url: Option, } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] pub struct CombatIndicator { pub combat_type: String, pub count: String, @@ -442,6 +442,191 @@ pub fn parse_guide(gid: &str, name: &str, csv: &str) -> GuideData { parse_guide_with_links(gid, name, csv, &std::collections::HashMap::new()) } +#[cfg(test)] +mod tests { + use super::*; + + // --- Helpers --------------------------------------------------------------- + + /// Enveloppe un contenu dans un `
` complet pour simuler + /// la structure réelle de dofuspourlesnoobs.com. + fn para(inner: &str) -> String { + format!(r#"
{}
"#, inner) + } + + // --- Tests ----------------------------------------------------------------- + + /// Cas 1 : aucun `
` contenant "À prévoir" → Vec vide. + #[test] + fn test_section_absente() { + let html = r#"

Rien ici

"#; + assert_eq!(extract_a_prevoir(html), vec![]); + } + + /// Cas 2 : section présente mais `
    ` vide → Vec vide. + #[test] + fn test_section_vide() { + let html = para( + r#"À prévoir : +
      + Prochaine section"#, + ); + assert_eq!(extract_a_prevoir(&html), vec![]); + } + + /// Cas 3 : combat solo — `1 x combat seul.` + #[test] + fn test_combat_solo() { + let html = para( + r#"À prévoir : +
        +
      • 1 x combat seul.
      • +
      "#, + ); + let result = extract_a_prevoir(&html); + assert_eq!(result.len(), 1); + assert_eq!(result[0].combat_type, "solo"); + assert_eq!(result[0].count, "1"); + assert_eq!(result[0].label, None); + assert!(!result[0].evitable); + } + + /// Cas 4 : combat groupe — `2 x combats (réalisable en groupe).` + #[test] + fn test_combat_groupe() { + let html = para( + r#"À prévoir : +
        +
      • 2 x combats (réalisable en groupe).
      • +
      "#, + ); + let result = extract_a_prevoir(&html); + assert_eq!(result.len(), 1); + assert_eq!(result[0].combat_type, "groupe"); + assert_eq!(result[0].count, "2"); + assert!(!result[0].evitable); + } + + /// Cas 5 : donjon avec label — `1 x Donjon Antre du Dragon Cochon.` + #[test] + fn test_donjon_avec_label() { + let html = para( + r#"À prévoir : +
        +
      • 1 x Donjon Antre du Dragon Cochon.
      • +
      "#, + ); + let result = extract_a_prevoir(&html); + assert_eq!(result.len(), 1); + assert_eq!(result[0].combat_type, "donjon"); + assert_eq!(result[0].count, "1"); + assert_eq!(result[0].label, Some("Antre du Dragon Cochon".to_string())); + assert!(!result[0].evitable); + } + + /// Cas 6 : item nommé — `1 x Parchemin de Frigost.` + #[test] + fn test_item_nomme() { + let html = para( + r#"À prévoir : +
        +
      • 1 x Parchemin de Frigost.
      • +
      "#, + ); + let result = extract_a_prevoir(&html); + assert_eq!(result.len(), 1); + assert_eq!(result[0].combat_type, "item"); + assert_eq!(result[0].count, "1"); + assert_eq!(result[0].label, Some("Parchemin de Frigost".to_string())); + assert!(!result[0].evitable); + } + + /// Cas 7 : combat évitable — `1 x combat seul (évitable).` + #[test] + fn test_combat_evitable() { + let html = para( + r#"À prévoir : +
        +
      • 1 x combat seul (évitable).
      • +
      "#, + ); + let result = extract_a_prevoir(&html); + assert_eq!(result.len(), 1); + assert_eq!(result[0].combat_type, "solo"); + assert!(result[0].evitable); + } + + /// Cas 8 : quantité "Des" — `Des combats contre des monstres.` → count "?", type "combat_zone" + #[test] + fn test_quantite_des() { + let html = para( + r#"À prévoir : +
        +
      • Des combats contre des monstres.
      • +
      "#, + ); + let result = extract_a_prevoir(&html); + assert_eq!(result.len(), 1); + assert_eq!(result[0].count, "?"); + assert_eq!(result[0].combat_type, "combat_zone"); + } + + /// Cas 9 : section "À savoir" présente au lieu de "À prévoir" → Vec vide. + #[test] + fn test_a_savoir_ne_matche_pas() { + let html = para( + r#"À savoir : +
        +
      • 1 x combat seul.
      • +
      "#, + ); + assert_eq!(extract_a_prevoir(&html), vec![]); + } + + /// Cas 10 : plusieurs items dans une même section — vérifier l'ordre et le count. + #[test] + fn test_multiple_items() { + let html = para( + r#"À prévoir : +
        +
      • 1 x combat seul.
      • +
      • 2 x combats (réalisable en groupe).
      • +
      • 1 x Donjon Antre du Dragon Cochon.
      • +
      "#, + ); + let result = extract_a_prevoir(&html); + assert_eq!(result.len(), 3); + + assert_eq!(result[0].combat_type, "solo"); + assert_eq!(result[0].count, "1"); + + assert_eq!(result[1].combat_type, "groupe"); + assert_eq!(result[1].count, "2"); + + assert_eq!(result[2].combat_type, "donjon"); + assert_eq!(result[2].count, "1"); + assert_eq!(result[2].label, Some("Antre du Dragon Cochon".to_string())); + } + + /// Cas 11 : entités HTML dans inner_html() — `À prévoir` doit être reconnu. + /// La crate `scraper` produit de l'inner_html avec ces entités non décodées. + /// On injecte la fixture en HTML brut avec entités comme le ferait scraper. + #[test] + fn test_entites_html() { + // On forge un HTML dont le inner_html() contiendra les entités telles quelles. + // scraper::Html::parse_document décode les entités dans .text() mais + // .inner_html() les re-sérialise en entités ASCII pour les caractères non-ASCII. + // Ici on passe directement le HTML avec les entités dans le . + let html = format!( + r#"
      À prévoir :
      • 1 x combat seul.
      "# + ); + let result = extract_a_prevoir(&html); + assert_eq!(result.len(), 1, "La regex doit reconnaitre À prévoir"); + assert_eq!(result[0].combat_type, "solo"); + assert_eq!(result[0].count, "1"); + } +} + pub fn parse_guide_with_links( gid: &str, name: &str, diff --git a/src/__mocks__/@tauri-apps/api/core.ts b/src/__mocks__/@tauri-apps/api/core.ts new file mode 100644 index 0000000..c6efd6c --- /dev/null +++ b/src/__mocks__/@tauri-apps/api/core.ts @@ -0,0 +1 @@ +export const invoke = vi.fn().mockResolvedValue(null); diff --git a/src/__tests__/HomeView.test.tsx b/src/__tests__/HomeView.test.tsx new file mode 100644 index 0000000..6580791 --- /dev/null +++ b/src/__tests__/HomeView.test.tsx @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import HomeView from "../components/HomeView"; + +// État minimal du store pour que HomeView ne plante pas +const baseStoreState = { + guides: [], + profiles: [], + activeProfileId: null, + syncing: false, + openGuide: vi.fn(), +}; + +vi.mock("../store", () => ({ + useStore: vi.fn((selector?: (s: unknown) => unknown) => { + return typeof selector === "function" + ? selector(baseStoreState) + : baseStoreState; + }), +})); + +describe("HomeView", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('ne montre pas la bannière "Première utilisation" sans needsSync', () => { + render(); + expect(screen.queryByText("Première utilisation")).not.toBeInTheDocument(); + }); + + it('affiche la bannière "Première utilisation" quand needsSync est true', () => { + render(); + expect(screen.getByText("Première utilisation")).toBeInTheDocument(); + }); + + it('affiche le bouton "Synchroniser les guides" quand needsSync est true', () => { + render(); + expect( + screen.getByRole("button", { name: /Synchroniser les guides/i }) + ).toBeInTheDocument(); + }); + + it("appelle onSync au clic sur le bouton de synchronisation", async () => { + const user = userEvent.setup(); + const onSync = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: /Synchroniser les guides/i })); + + expect(onSync).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/__tests__/ProfileModal.test.tsx b/src/__tests__/ProfileModal.test.tsx new file mode 100644 index 0000000..24e2b87 --- /dev/null +++ b/src/__tests__/ProfileModal.test.tsx @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import ProfileModal from "../components/ProfileModal"; + +// Mock du store Zustand — on expose des données neutres et des fonctions no-op +vi.mock("../store", () => ({ + useStore: vi.fn((selector?: (s: unknown) => unknown) => { + const state = { + profiles: [], + activeProfileId: null, + setActiveProfile: vi.fn(), + createProfile: vi.fn(), + deleteProfile: vi.fn(), + }; + // Zustand permet d'appeler useStore avec ou sans sélecteur + return typeof selector === "function" ? selector(state) : state; + }), +})); + +const noop = () => {}; + +describe("ProfileModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("mode normal (blocking absent)", () => { + it("affiche le bouton de fermeture ✕", () => { + render(); + expect(screen.getByRole("button", { name: "✕" })).toBeInTheDocument(); + }); + + it('affiche le titre "Profils"', () => { + render(); + expect(screen.getByRole("heading", { name: "Profils" })).toBeInTheDocument(); + }); + }); + + describe("mode blocking", () => { + it("ne rend pas le bouton de fermeture ✕", () => { + render(); + expect(screen.queryByRole("button", { name: "✕" })).not.toBeInTheDocument(); + }); + + it('affiche le titre "Bienvenue — créez votre profil"', () => { + render(); + expect( + screen.getByRole("heading", { name: "Bienvenue — créez votre profil" }) + ).toBeInTheDocument(); + }); + }); +}); diff --git a/src/__tests__/collectQuestUrls.test.ts b/src/__tests__/collectQuestUrls.test.ts new file mode 100644 index 0000000..89796f5 --- /dev/null +++ b/src/__tests__/collectQuestUrls.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest"; +import { collectQuestUrls } from "../store"; +import type { Section } from "../types"; + +// Helpers pour construire des fixtures lisibles +function makeQuestItem(name: string, url: string | null = null) { + return { name, completed: false, combat_indicators: [], note: null, url }; +} + +function makeQuestSection(items: Section["items"]): Section { + return { name: "Section test", items }; +} + +describe("collectQuestUrls", () => { + it("retourne [] pour un tableau de sections vides", () => { + expect(collectQuestUrls([])).toEqual([]); + }); + + it("retourne [] si la quête n'a pas d'URL", () => { + const sections: Section[] = [ + makeQuestSection([{ type: "Quest", ...makeQuestItem("Quête sans URL") }]), + ]; + expect(collectQuestUrls(sections)).toEqual([]); + }); + + it("retourne l'URL d'une quête avec URL", () => { + const sections: Section[] = [ + makeQuestSection([ + { type: "Quest", ...makeQuestItem("Quête avec URL", "https://example.com/quest/1") }, + ]), + ]; + expect(collectQuestUrls(sections)).toEqual(["https://example.com/quest/1"]); + }); + + it("retourne toutes les URLs d'un Group", () => { + const sections: Section[] = [ + makeQuestSection([ + { + type: "Group", + note: null, + quests: [ + makeQuestItem("Q1", "https://example.com/q1"), + makeQuestItem("Q2", "https://example.com/q2"), + makeQuestItem("Q3 sans URL"), + ], + }, + ]), + ]; + expect(collectQuestUrls(sections)).toEqual([ + "https://example.com/q1", + "https://example.com/q2", + ]); + }); + + it("collecte les URLs de Quest et Group mais pas des Instruction", () => { + const sections: Section[] = [ + makeQuestSection([ + { type: "Quest", ...makeQuestItem("Q directe", "https://example.com/direct") }, + { + type: "Group", + note: null, + quests: [makeQuestItem("Q groupe", "https://example.com/groupe")], + }, + { type: "Instruction", text: "Allez au point de départ" }, + ]), + ]; + expect(collectQuestUrls(sections)).toEqual([ + "https://example.com/direct", + "https://example.com/groupe", + ]); + }); + + it("inclut les deux occurrences si la même URL apparaît deux fois (pas de déduplication)", () => { + const url = "https://example.com/shared"; + const sections: Section[] = [ + makeQuestSection([ + { type: "Quest", ...makeQuestItem("Q1", url) }, + { type: "Quest", ...makeQuestItem("Q2", url) }, + ]), + ]; + const result = collectQuestUrls(sections); + expect(result).toEqual([url, url]); + expect(result).toHaveLength(2); + }); + + it("fonctionne avec plusieurs sections", () => { + const sections: Section[] = [ + { name: "Section A", items: [{ type: "Quest", ...makeQuestItem("QA", "https://example.com/a") }] }, + { name: "Section B", items: [{ type: "Quest", ...makeQuestItem("QB", "https://example.com/b") }] }, + ]; + expect(collectQuestUrls(sections)).toEqual([ + "https://example.com/a", + "https://example.com/b", + ]); + }); +}); diff --git a/src/__tests__/combatIcon.test.ts b/src/__tests__/combatIcon.test.ts new file mode 100644 index 0000000..36b3d80 --- /dev/null +++ b/src/__tests__/combatIcon.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { combatIcon } from "../components/GuideView"; + +describe("combatIcon", () => { + it('retourne "🗡️" pour "solo"', () => { + expect(combatIcon("solo")).toBe("🗡️"); + }); + + it('retourne "⚔️" pour "groupe"', () => { + expect(combatIcon("groupe")).toBe("⚔️"); + }); + + it('retourne "💀" pour "donjon"', () => { + expect(combatIcon("donjon")).toBe("💀"); + }); + + it('retourne "🗺️" pour "deplacement"', () => { + expect(combatIcon("deplacement")).toBe("🗺️"); + }); + + it('retourne "📦" pour "item"', () => { + expect(combatIcon("item")).toBe("📦"); + }); + + it('retourne "🗡️" pour "combat_vagues" (cas explicite)', () => { + expect(combatIcon("combat_vagues")).toBe("🗡️"); + }); + + it('retourne "🗡️" (fallback) pour une valeur inconnue', () => { + expect(combatIcon("inconnu")).toBe("🗡️"); + }); + + it("est insensible à la casse", () => { + expect(combatIcon("SOLO")).toBe("🗡️"); + expect(combatIcon("Groupe")).toBe("⚔️"); + }); +}); diff --git a/src/components/GuideView.tsx b/src/components/GuideView.tsx index 7394d8b..3b4a3c8 100644 --- a/src/components/GuideView.tsx +++ b/src/components/GuideView.tsx @@ -14,7 +14,7 @@ function useWindowWidth() { return width; } -function combatIcon(name: string): string { +export function combatIcon(name: string): string { const l = name.toLowerCase(); if (l === "solo") return "🗡️"; if (l === "groupe") return "⚔️"; diff --git a/src/store.ts b/src/store.ts index c1d8a22..5353159 100644 --- a/src/store.ts +++ b/src/store.ts @@ -189,7 +189,7 @@ export const useStore = create((set, get) => ({ }, })); -function collectQuestUrls(sections: Section[]): string[] { +export function collectQuestUrls(sections: Section[]): string[] { const urls: string[] = []; for (const section of sections) { for (const item of section.items) { diff --git a/src/test-setup.ts b/src/test-setup.ts new file mode 100644 index 0000000..d0de870 --- /dev/null +++ b/src/test-setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom"; diff --git a/vite.config.ts b/vite.config.ts index 429a2de..21e4337 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,3 +1,4 @@ +/// import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; @@ -9,6 +10,12 @@ const host = process.env.TAURI_DEV_HOST; export default defineConfig(async () => ({ plugins: [react(), tailwindcss()], + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./src/test-setup.ts"], + }, + // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // // 1. prevent Vite from obscuring rust errors