feat: add unit tests (Rust parser+DB, Vitest frontend) and test workflow

This commit is contained in:
2026-04-25 15:11:25 +02:00
parent b42674b22c
commit 8fd71de1aa
15 changed files with 689 additions and 5 deletions

View File

@ -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

View File

@ -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.

View File

@ -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.

View File

@ -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<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");
}
}

View File

@ -46,7 +46,7 @@ pub struct QuestItem {
pub url: Option<String>,
}
#[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 `<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() — `&Agrave; pr&eacute;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>&Agrave; pr&eacute;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 &Agrave; pr&eacute;voir");
assert_eq!(result[0].combat_type, "solo");
assert_eq!(result[0].count, "1");
}
}
pub fn parse_guide_with_links(
gid: &str,
name: &str,