feat: add unit tests (Rust parser+DB, Vitest frontend) and test workflow
This commit is contained in:
12
package.json
12
package.json
@ -7,7 +7,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.99.2",
|
"@tanstack/react-query": "^5.99.2",
|
||||||
@ -22,12 +24,18 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.2.4",
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
"@tauri-apps/cli": "^2",
|
"@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": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"@vitest/coverage-v8": "^3.0.0",
|
||||||
"esbuild": "^0.28.0",
|
"esbuild": "^0.28.0",
|
||||||
|
"jsdom": "^26.0.0",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^8.0.9"
|
"vite": "^8.0.9",
|
||||||
|
"vitest": "^3.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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<T,String>, placeholders SQL positionnels, pattern test_db() in-memory
|
||||||
@ -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.
|
||||||
@ -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<Connection>)` dans `commands.rs` — une seule connexion SQLite partagée via Mutex.
|
||||||
|
|
||||||
|
## Propagation d'erreurs
|
||||||
|
Les commandes Tauri retournent `Result<T, String>` : `.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<CombatIndicator>` 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<String>,
|
||||||
|
#[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.
|
||||||
@ -351,3 +351,184 @@ pub fn set_setting(conn: &Connection, key: &str, value: &str) -> Result<()> {
|
|||||||
)?;
|
)?;
|
||||||
Ok(())
|
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<String> = (1..=3)
|
||||||
|
.map(|i| format!("https://example.com/quete/{}", i))
|
||||||
|
.collect();
|
||||||
|
let extra: Vec<String> = (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<String> = 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -46,7 +46,7 @@ pub struct QuestItem {
|
|||||||
pub url: Option<String>,
|
pub url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||||
pub struct CombatIndicator {
|
pub struct CombatIndicator {
|
||||||
pub combat_type: String,
|
pub combat_type: String,
|
||||||
pub count: 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())
|
parse_guide_with_links(gid, name, csv, &std::collections::HashMap::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// --- Helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Enveloppe un contenu dans un `<div class="paragraph">` complet pour simuler
|
||||||
|
/// la structure réelle de dofuspourlesnoobs.com.
|
||||||
|
fn para(inner: &str) -> String {
|
||||||
|
format!(r#"<html><body><div class="paragraph">{}</div></body></html>"#, inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tests -----------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Cas 1 : aucun `<div class="paragraph">` contenant "À prévoir" → Vec vide.
|
||||||
|
#[test]
|
||||||
|
fn test_section_absente() {
|
||||||
|
let html = r#"<html><body><p>Rien ici</p></body></html>"#;
|
||||||
|
assert_eq!(extract_a_prevoir(html), vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cas 2 : section présente mais `<ul>` vide → Vec vide.
|
||||||
|
#[test]
|
||||||
|
fn test_section_vide() {
|
||||||
|
let html = para(
|
||||||
|
r#"<strong>À prévoir :</strong>
|
||||||
|
<ul></ul>
|
||||||
|
<strong>Prochaine section</strong>"#,
|
||||||
|
);
|
||||||
|
assert_eq!(extract_a_prevoir(&html), vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cas 3 : combat solo — `1 x combat seul.`
|
||||||
|
#[test]
|
||||||
|
fn test_combat_solo() {
|
||||||
|
let html = para(
|
||||||
|
r#"<strong>À prévoir :</strong>
|
||||||
|
<ul>
|
||||||
|
<li>1 x combat seul.</li>
|
||||||
|
</ul>"#,
|
||||||
|
);
|
||||||
|
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#"<strong>À prévoir :</strong>
|
||||||
|
<ul>
|
||||||
|
<li>2 x combats (réalisable en groupe).</li>
|
||||||
|
</ul>"#,
|
||||||
|
);
|
||||||
|
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#"<strong>À prévoir :</strong>
|
||||||
|
<ul>
|
||||||
|
<li>1 x Donjon Antre du Dragon Cochon.</li>
|
||||||
|
</ul>"#,
|
||||||
|
);
|
||||||
|
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#"<strong>À prévoir :</strong>
|
||||||
|
<ul>
|
||||||
|
<li>1 x Parchemin de Frigost.</li>
|
||||||
|
</ul>"#,
|
||||||
|
);
|
||||||
|
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#"<strong>À prévoir :</strong>
|
||||||
|
<ul>
|
||||||
|
<li>1 x combat seul (évitable).</li>
|
||||||
|
</ul>"#,
|
||||||
|
);
|
||||||
|
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#"<strong>À prévoir :</strong>
|
||||||
|
<ul>
|
||||||
|
<li>Des combats contre des monstres.</li>
|
||||||
|
</ul>"#,
|
||||||
|
);
|
||||||
|
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#"<strong>À savoir :</strong>
|
||||||
|
<ul>
|
||||||
|
<li>1 x combat seul.</li>
|
||||||
|
</ul>"#,
|
||||||
|
);
|
||||||
|
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#"<strong>À prévoir :</strong>
|
||||||
|
<ul>
|
||||||
|
<li>1 x combat seul.</li>
|
||||||
|
<li>2 x combats (réalisable en groupe).</li>
|
||||||
|
<li>1 x Donjon Antre du Dragon Cochon.</li>
|
||||||
|
</ul>"#,
|
||||||
|
);
|
||||||
|
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 <strong>.
|
||||||
|
let html = format!(
|
||||||
|
r#"<html><body><div class="paragraph"><strong>À prévoir :</strong><ul><li>1 x combat seul.</li></ul></div></body></html>"#
|
||||||
|
);
|
||||||
|
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(
|
pub fn parse_guide_with_links(
|
||||||
gid: &str,
|
gid: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
|
|||||||
1
src/__mocks__/@tauri-apps/api/core.ts
Normal file
1
src/__mocks__/@tauri-apps/api/core.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const invoke = vi.fn().mockResolvedValue(null);
|
||||||
54
src/__tests__/HomeView.test.tsx
Normal file
54
src/__tests__/HomeView.test.tsx
Normal file
@ -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(<HomeView />);
|
||||||
|
expect(screen.queryByText("Première utilisation")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('affiche la bannière "Première utilisation" quand needsSync est true', () => {
|
||||||
|
render(<HomeView needsSync={true} />);
|
||||||
|
expect(screen.getByText("Première utilisation")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('affiche le bouton "Synchroniser les guides" quand needsSync est true', () => {
|
||||||
|
render(<HomeView needsSync={true} onSync={vi.fn()} />);
|
||||||
|
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(<HomeView needsSync={true} onSync={onSync} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: /Synchroniser les guides/i }));
|
||||||
|
|
||||||
|
expect(onSync).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
52
src/__tests__/ProfileModal.test.tsx
Normal file
52
src/__tests__/ProfileModal.test.tsx
Normal file
@ -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(<ProfileModal onClose={noop} />);
|
||||||
|
expect(screen.getByRole("button", { name: "✕" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('affiche le titre "Profils"', () => {
|
||||||
|
render(<ProfileModal onClose={noop} />);
|
||||||
|
expect(screen.getByRole("heading", { name: "Profils" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mode blocking", () => {
|
||||||
|
it("ne rend pas le bouton de fermeture ✕", () => {
|
||||||
|
render(<ProfileModal onClose={noop} blocking />);
|
||||||
|
expect(screen.queryByRole("button", { name: "✕" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('affiche le titre "Bienvenue — créez votre profil"', () => {
|
||||||
|
render(<ProfileModal onClose={noop} blocking />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Bienvenue — créez votre profil" })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
96
src/__tests__/collectQuestUrls.test.ts
Normal file
96
src/__tests__/collectQuestUrls.test.ts
Normal file
@ -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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
37
src/__tests__/combatIcon.test.ts
Normal file
37
src/__tests__/combatIcon.test.ts
Normal file
@ -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("⚔️");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -14,7 +14,7 @@ function useWindowWidth() {
|
|||||||
return width;
|
return width;
|
||||||
}
|
}
|
||||||
|
|
||||||
function combatIcon(name: string): string {
|
export function combatIcon(name: string): string {
|
||||||
const l = name.toLowerCase();
|
const l = name.toLowerCase();
|
||||||
if (l === "solo") return "🗡️";
|
if (l === "solo") return "🗡️";
|
||||||
if (l === "groupe") return "⚔️";
|
if (l === "groupe") return "⚔️";
|
||||||
|
|||||||
@ -189,7 +189,7 @@ export const useStore = create<AppState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function collectQuestUrls(sections: Section[]): string[] {
|
export function collectQuestUrls(sections: Section[]): string[] {
|
||||||
const urls: string[] = [];
|
const urls: string[] = [];
|
||||||
for (const section of sections) {
|
for (const section of sections) {
|
||||||
for (const item of section.items) {
|
for (const item of section.items) {
|
||||||
|
|||||||
1
src/test-setup.ts
Normal file
1
src/test-setup.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
import "@testing-library/jest-dom";
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
/// <reference types="vitest" />
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
@ -9,6 +10,12 @@ const host = process.env.TAURI_DEV_HOST;
|
|||||||
export default defineConfig(async () => ({
|
export default defineConfig(async () => ({
|
||||||
plugins: [react(), tailwindcss()],
|
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`
|
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||||
//
|
//
|
||||||
// 1. prevent Vite from obscuring rust errors
|
// 1. prevent Vite from obscuring rust errors
|
||||||
|
|||||||
Reference in New Issue
Block a user