⎇ d3649bfd1a80153652c2a028315369bb4b184024
crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs
Historia
Quién tocó qué
crudo
feat(tahuantinsuyu): fase 17 — filtros de aspectos + editor + cleanup + labels
1//! `tahuantinsuyu-store` — persistencia SQLite del estudio astrológico.
2//!
3//! Una sola conexión `rusqlite` envuelta en `Arc<Mutex>` para que la app
4//! GPUI la comparta entre threads sin pelearse con el ownership. La
5//! migración inicial corre la primera vez que se abre un archivo nuevo
6//! (idempotente vía `CREATE TABLE IF NOT EXISTS`).
7//!
8//! Patrón inspirado en `yahweh_provider_sqlite::SqliteDataProvider` pero
9//! con dominio propio (no extiende el `DataProvider` agnóstico — esa
10//! integración viene en `tahuantinsuyu-tree` que envuelve este store
11//! detrás del trait de yahweh).
12
13#![forbid(unsafe_code)]
14#![warn(rust_2018_idioms)]
15
16use std::path::Path;
17use std::sync::{Arc, Mutex};
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use rusqlite::{Connection, OptionalExtension, params};
21use thiserror::Error;
22
23use tahuantinsuyu_model::{
24 Chart, ChartId, ChartKind, Contact, ContactId, Group, GroupId, ModuleState, StoredBirthData,
25 StoredChartConfig,
26};
27
28const SCHEMA_VERSION: i32 = 1;
29
30#[derive(Debug, Error)]
31pub enum StoreError {
32 #[error("sqlite: {0}")]
33 Sqlite(#[from] rusqlite::Error),
34 #[error("json: {0}")]
35 Json(#[from] serde_json::Error),
36 #[error("schema downgrade: db is at v{found}, code expects v{expected}")]
37 SchemaDowngrade { found: i32, expected: i32 },
38 #[error("ulid decode: {0}")]
39 UlidDecode(#[from] ulid::DecodeError),
40 #[error("model invariant: {0}")]
41 Model(#[from] tahuantinsuyu_model::ModelError),
42 #[error("not found: {0}")]
43 NotFound(String),
44}
45
46pub type StoreResult<T> = Result<T, StoreError>;
47
48/// Store backed by a single SQLite file.
49///
50/// Clone-able: comparte la misma conexión bajo el mutex. Útil para que
51/// distintos widgets (tree, panel, canvas) compartan una vista
52/// consistente sin pasar `&mut` por todos lados.
53#[derive(Clone)]
54pub struct Store {
55 conn: Arc<Mutex<Connection>>,
56}
57
58impl Store {
59 /// Abre (o crea) un archivo SQLite y corre las migraciones.
60 pub fn open(path: impl AsRef<Path>) -> StoreResult<Self> {
61 let conn = Connection::open(path)?;
62 let store = Self {
63 conn: Arc::new(Mutex::new(conn)),
64 };
65 store.migrate()?;
66 Ok(store)
67 }
68
69 /// Variante in-memory para tests.
70 pub fn in_memory() -> StoreResult<Self> {
71 let conn = Connection::open_in_memory()?;
72 let store = Self {
73 conn: Arc::new(Mutex::new(conn)),
74 };
75 store.migrate()?;
76 Ok(store)
77 }
78
79 fn migrate(&self) -> StoreResult<()> {
80 let conn = self.conn.lock().unwrap();
81 conn.execute_batch(MIGRATION_V1)?;
82
83 let found: i32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
84 if found > SCHEMA_VERSION {
85 return Err(StoreError::SchemaDowngrade {
86 found,
87 expected: SCHEMA_VERSION,
88 });
89 }
90 if found < SCHEMA_VERSION {
91 conn.execute(&format!("PRAGMA user_version = {}", SCHEMA_VERSION), [])?;
92 }
93 Ok(())
94 }
95
96 // -----------------------------------------------------------------
97 // Groups
98 // -----------------------------------------------------------------
99
100 pub fn create_group(
101 &self,
102 parent_id: Option<GroupId>,
103 name: &str,
104 description: Option<&str>,
105 ) -> StoreResult<Group> {
106 let group = Group {
107 id: GroupId::new(),
108 parent_id,
109 name: name.into(),
110 description: description.map(String::from),
111 created_at_ms: now_ms(),
112 sort_order: 0,
113 };
114 let conn = self.conn.lock().unwrap();
115 conn.execute(
116 "INSERT INTO groups (id, parent_id, name, description, created_at_ms, sort_order) \
117 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
118 params![
119 group.id.to_string(),
120 group.parent_id.map(|g| g.to_string()),
121 group.name,
122 group.description,
123 group.created_at_ms,
124 group.sort_order,
125 ],
126 )?;
127 Ok(group)
128 }
129
130 pub fn list_groups(&self, parent_id: Option<GroupId>) -> StoreResult<Vec<Group>> {
131 let conn = self.conn.lock().unwrap();
132 let mut stmt = conn.prepare(
133 "SELECT id, parent_id, name, description, created_at_ms, sort_order \
134 FROM groups WHERE parent_id IS ?1 \
135 ORDER BY sort_order ASC, name COLLATE NOCASE ASC",
136 )?;
137 let parent_str = parent_id.map(|g| g.to_string());
138 let rows = stmt.query_map(params![parent_str], row_to_group)?;
139 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
140 }
141
142 pub fn delete_group(&self, id: GroupId) -> StoreResult<()> {
143 let conn = self.conn.lock().unwrap();
144 conn.execute("DELETE FROM groups WHERE id = ?1", params![id.to_string()])?;
145 Ok(())
146 }
147
148 pub fn rename_group(&self, id: GroupId, name: &str) -> StoreResult<()> {
149 let conn = self.conn.lock().unwrap();
150 conn.execute(
151 "UPDATE groups SET name = ?2 WHERE id = ?1",
152 params![id.to_string(), name],
153 )?;
154 Ok(())
155 }
156
157 /// Cambia el `parent_id` de un Group. Pasar `None` para mover a raíz.
158 /// **No** valida ciclos — el caller debe garantizar que el nuevo
159 /// padre no sea descendiente del que mueve (sino la DB queda con un
160 /// ciclo que el list_groups no rompe pero hace al CTE infinito).
161 pub fn move_group(&self, id: GroupId, new_parent: Option<GroupId>) -> StoreResult<()> {
162 let conn = self.conn.lock().unwrap();
163 conn.execute(
164 "UPDATE groups SET parent_id = ?2 WHERE id = ?1",
165 params![id.to_string(), new_parent.map(|g| g.to_string())],
166 )?;
167 Ok(())
168 }
169
170 // -----------------------------------------------------------------
171 // Contacts
172 // -----------------------------------------------------------------
173
174 pub fn create_contact(
175 &self,
176 group_id: Option<GroupId>,
177 name: &str,
178 notes: Option<&str>,
179 ) -> StoreResult<Contact> {
180 let c = Contact {
181 id: ContactId::new(),
182 group_id,
183 name: name.into(),
184 notes: notes.map(String::from),
185 created_at_ms: now_ms(),
186 };
187 let conn = self.conn.lock().unwrap();
188 conn.execute(
189 "INSERT INTO contacts (id, group_id, name, notes, created_at_ms) \
190 VALUES (?1, ?2, ?3, ?4, ?5)",
191 params![
192 c.id.to_string(),
193 c.group_id.map(|g| g.to_string()),
194 c.name,
195 c.notes,
196 c.created_at_ms,
197 ],
198 )?;
199 Ok(c)
200 }
201
202 pub fn list_contacts(&self, group_id: Option<GroupId>) -> StoreResult<Vec<Contact>> {
203 let conn = self.conn.lock().unwrap();
204 let mut stmt = conn.prepare(
205 "SELECT id, group_id, name, notes, created_at_ms \
206 FROM contacts WHERE group_id IS ?1 \
207 ORDER BY name COLLATE NOCASE ASC",
208 )?;
209 let g = group_id.map(|g| g.to_string());
210 let rows = stmt.query_map(params![g], row_to_contact)?;
211 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
212 }
213
214 pub fn delete_contact(&self, id: ContactId) -> StoreResult<()> {
215 let conn = self.conn.lock().unwrap();
216 conn.execute("DELETE FROM contacts WHERE id = ?1", params![id.to_string()])?;
217 Ok(())
218 }
219
220 pub fn rename_contact(&self, id: ContactId, name: &str) -> StoreResult<()> {
221 let conn = self.conn.lock().unwrap();
222 conn.execute(
223 "UPDATE contacts SET name = ?2 WHERE id = ?1",
224 params![id.to_string(), name],
225 )?;
226 Ok(())
227 }
228
229 pub fn move_contact(&self, id: ContactId, new_group: Option<GroupId>) -> StoreResult<()> {
230 let conn = self.conn.lock().unwrap();
231 conn.execute(
232 "UPDATE contacts SET group_id = ?2 WHERE id = ?1",
233 params![id.to_string(), new_group.map(|g| g.to_string())],
234 )?;
235 Ok(())
236 }
237
238 // -----------------------------------------------------------------
239 // Charts
240 // -----------------------------------------------------------------
241
242 pub fn create_chart(
243 &self,
244 contact_id: ContactId,
245 kind: ChartKind,
246 label: &str,
247 birth: &StoredBirthData,
248 config: &StoredChartConfig,
249 related_chart_id: Option<ChartId>,
250 ) -> StoreResult<Chart> {
251 let chart = Chart {
252 id: ChartId::new(),
253 contact_id,
254 kind,
255 label: label.into(),
256 birth_data: birth.clone(),
257 config: config.clone(),
258 related_chart_id,
259 created_at_ms: now_ms(),
260 };
261 chart.validate()?;
262 let conn = self.conn.lock().unwrap();
263 conn.execute(
264 "INSERT INTO charts \
265 (id, contact_id, kind, label, birth_data_json, config_json, \
266 related_chart_id, created_at_ms) \
267 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
268 params![
269 chart.id.to_string(),
270 chart.contact_id.to_string(),
271 serde_json::to_string(&chart.kind)?,
272 chart.label,
273 serde_json::to_string(&chart.birth_data)?,
274 serde_json::to_string(&chart.config)?,
275 chart.related_chart_id.map(|c| c.to_string()),
276 chart.created_at_ms,
277 ],
278 )?;
279 Ok(chart)
280 }
281
282 pub fn list_charts(&self, contact_id: ContactId) -> StoreResult<Vec<Chart>> {
283 let conn = self.conn.lock().unwrap();
284 let mut stmt = conn.prepare(
285 "SELECT id, contact_id, kind, label, birth_data_json, config_json, \
286 related_chart_id, created_at_ms \
287 FROM charts WHERE contact_id = ?1 \
288 ORDER BY created_at_ms ASC",
289 )?;
290 let rows = stmt.query_map(params![contact_id.to_string()], row_to_chart)?;
291 rows.collect::<Result<Vec<_>, _>>()
292 .map_err(StoreError::from)
293 .and_then(|v| v.into_iter().collect::<StoreResult<Vec<_>>>())
294 }
295
296 /// Lista todas las cartas del DB ordenadas por label (case-insensitive).
297 /// Pensado para pickers / selectores cross-contact (ej. elegir un
298 /// partner de sinastría desde cualquier contacto).
299 pub fn list_all_charts(&self) -> StoreResult<Vec<Chart>> {
300 let conn = self.conn.lock().unwrap();
301 let mut stmt = conn.prepare(
302 "SELECT id, contact_id, kind, label, birth_data_json, config_json, \
303 related_chart_id, created_at_ms \
304 FROM charts ORDER BY label COLLATE NOCASE ASC",
305 )?;
306 let rows = stmt.query_map([], row_to_chart)?;
307 let mut out = Vec::new();
308 for r in rows {
309 out.push(r??);
310 }
311 Ok(out)
312 }
313
314 pub fn get_chart(&self, id: ChartId) -> StoreResult<Chart> {
315 let conn = self.conn.lock().unwrap();
316 let mut stmt = conn.prepare(
317 "SELECT id, contact_id, kind, label, birth_data_json, config_json, \
318 related_chart_id, created_at_ms \
319 FROM charts WHERE id = ?1",
320 )?;
321 let chart = stmt
322 .query_row(params![id.to_string()], row_to_chart)
323 .optional()?;
324 match chart {
325 Some(Ok(c)) => Ok(c),
326 Some(Err(e)) => Err(e),
327 None => Err(StoreError::NotFound(format!("chart {}", id))),
328 }
329 }
330
331 pub fn delete_chart(&self, id: ChartId) -> StoreResult<()> {
332 let conn = self.conn.lock().unwrap();
333 conn.execute("DELETE FROM charts WHERE id = ?1", params![id.to_string()])?;
334 Ok(())
335 }
336
337 pub fn rename_chart(&self, id: ChartId, label: &str) -> StoreResult<()> {
338 let conn = self.conn.lock().unwrap();
339 conn.execute(
340 "UPDATE charts SET label = ?2 WHERE id = ?1",
341 params![id.to_string(), label],
342 )?;
343 Ok(())
344 }
345
346 /// Reemplaza label + birth_data + config de una carta existente,
347 /// preservando id / contact_id / related_chart_id / created_at_ms y
348 /// el `module_state` asociado (no se borra). Usado por el editor de
349 /// rectificación natal.
350 pub fn update_chart(
351 &self,
352 id: ChartId,
353 label: &str,
354 birth: &StoredBirthData,
355 config: &StoredChartConfig,
356 ) -> StoreResult<()> {
357 let conn = self.conn.lock().unwrap();
358 conn.execute(
359 "UPDATE charts SET label = ?2, birth_data_json = ?3, config_json = ?4 \
360 WHERE id = ?1",
361 params![
362 id.to_string(),
363 label,
364 serde_json::to_string(birth)?,
365 serde_json::to_string(config)?,
366 ],
367 )?;
368 Ok(())
369 }
370
371 // -----------------------------------------------------------------
372 // Module state
373 // -----------------------------------------------------------------
374
375 pub fn upsert_module_state(&self, state: &ModuleState) -> StoreResult<()> {
376 let conn = self.conn.lock().unwrap();
377 conn.execute(
378 "INSERT INTO module_state (chart_id, module_id, enabled, config_json) \
379 VALUES (?1, ?2, ?3, ?4) \
380 ON CONFLICT(chart_id, module_id) DO UPDATE SET \
381 enabled = excluded.enabled, \
382 config_json = excluded.config_json",
383 params![
384 state.chart_id.to_string(),
385 state.module_id,
386 state.enabled as i32,
387 state.config.to_string(),
388 ],
389 )?;
390 Ok(())
391 }
392
393 pub fn list_module_states(&self, chart_id: ChartId) -> StoreResult<Vec<ModuleState>> {
394 let conn = self.conn.lock().unwrap();
395 let mut stmt = conn.prepare(
396 "SELECT chart_id, module_id, enabled, config_json \
397 FROM module_state WHERE chart_id = ?1",
398 )?;
399 let rows = stmt.query_map(params![chart_id.to_string()], |row| {
400 Ok((
401 row.get::<_, String>(0)?,
402 row.get::<_, String>(1)?,
403 row.get::<_, i32>(2)?,
404 row.get::<_, String>(3)?,
405 ))
406 })?;
407 let mut out = Vec::new();
408 for r in rows {
409 let (chart_str, module_id, enabled, config_str) = r?;
410 out.push(ModuleState {
411 chart_id: chart_str
412 .parse()
413 .map_err(|e: ulid::DecodeError| StoreError::UlidDecode(e))?,
414 module_id,
415 enabled: enabled != 0,
416 config: serde_json::from_str(&config_str).unwrap_or(serde_json::Value::Null),
417 });
418 }
419 Ok(out)
420 }
421
422 // -----------------------------------------------------------------
423 // Recursive descent: charts under a group/contact (para thumbnails)
424 // -----------------------------------------------------------------
425
426 /// Devuelve todas las cartas que descienden de un Group (incluyendo
427 /// los Contacts de sub-groups recursivamente).
428 pub fn charts_under_group(&self, root: GroupId) -> StoreResult<Vec<Chart>> {
429 let conn = self.conn.lock().unwrap();
430 // CTE recursivo para listar todos los descendientes del group.
431 let mut stmt = conn.prepare(
432 "WITH RECURSIVE descendants(id) AS ( \
433 SELECT ?1 \
434 UNION ALL \
435 SELECT g.id FROM groups g JOIN descendants d ON g.parent_id = d.id \
436 ) \
437 SELECT c.id, c.contact_id, c.kind, c.label, c.birth_data_json, c.config_json, \
438 c.related_chart_id, c.created_at_ms \
439 FROM charts c \
440 JOIN contacts ct ON ct.id = c.contact_id \
441 WHERE ct.group_id IN descendants \
442 ORDER BY c.created_at_ms ASC",
443 )?;
444 let rows = stmt.query_map(params![root.to_string()], row_to_chart)?;
445 let mut out = Vec::new();
446 for r in rows {
447 out.push(r??);
448 }
449 Ok(out)
450 }
451}
452
453// =====================================================================
454// SQL schema
455// =====================================================================
456
457const MIGRATION_V1: &str = r#"
458PRAGMA foreign_keys = ON;
459
460CREATE TABLE IF NOT EXISTS groups (
461 id TEXT PRIMARY KEY,
462 parent_id TEXT,
463 name TEXT NOT NULL,
464 description TEXT,
465 created_at_ms INTEGER NOT NULL,
466 sort_order INTEGER NOT NULL DEFAULT 0,
467 FOREIGN KEY(parent_id) REFERENCES groups(id) ON DELETE CASCADE
468);
469CREATE INDEX IF NOT EXISTS idx_groups_parent ON groups(parent_id);
470
471CREATE TABLE IF NOT EXISTS contacts (
472 id TEXT PRIMARY KEY,
473 group_id TEXT,
474 name TEXT NOT NULL,
475 notes TEXT,
476 created_at_ms INTEGER NOT NULL,
477 FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE SET NULL
478);
479CREATE INDEX IF NOT EXISTS idx_contacts_group ON contacts(group_id);
480
481CREATE TABLE IF NOT EXISTS charts (
482 id TEXT PRIMARY KEY,
483 contact_id TEXT NOT NULL,
484 kind TEXT NOT NULL,
485 label TEXT NOT NULL,
486 birth_data_json TEXT NOT NULL,
487 config_json TEXT NOT NULL,
488 related_chart_id TEXT,
489 created_at_ms INTEGER NOT NULL,
490 FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE,
491 FOREIGN KEY(related_chart_id) REFERENCES charts(id) ON DELETE SET NULL
492);
493CREATE INDEX IF NOT EXISTS idx_charts_contact ON charts(contact_id);
494
495CREATE TABLE IF NOT EXISTS module_state (
496 chart_id TEXT NOT NULL,
497 module_id TEXT NOT NULL,
498 enabled INTEGER NOT NULL DEFAULT 0,
499 config_json TEXT NOT NULL DEFAULT '{}',
500 PRIMARY KEY(chart_id, module_id),
501 FOREIGN KEY(chart_id) REFERENCES charts(id) ON DELETE CASCADE
502);
503
504CREATE TABLE IF NOT EXISTS settings (
505 key TEXT PRIMARY KEY,
506 value TEXT NOT NULL
507);
508"#;
509
510// =====================================================================
511// Row decoders
512// =====================================================================
513
514fn row_to_group(row: &rusqlite::Row<'_>) -> rusqlite::Result<Group> {
515 let id_str: String = row.get(0)?;
516 let parent_id_str: Option<String> = row.get(1)?;
517 Ok(Group {
518 id: id_str
519 .parse()
520 .map_err(|e: ulid::DecodeError| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
521 parent_id: match parent_id_str {
522 Some(s) => Some(s.parse().map_err(|e: ulid::DecodeError| {
523 rusqlite::Error::ToSqlConversionFailure(Box::new(e))
524 })?),
525 None => None,
526 },
527 name: row.get(2)?,
528 description: row.get(3)?,
529 created_at_ms: row.get(4)?,
530 sort_order: row.get(5)?,
531 })
532}
533
534fn row_to_contact(row: &rusqlite::Row<'_>) -> rusqlite::Result<Contact> {
535 let id_str: String = row.get(0)?;
536 let group_str: Option<String> = row.get(1)?;
537 Ok(Contact {
538 id: id_str
539 .parse()
540 .map_err(|e: ulid::DecodeError| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
541 group_id: match group_str {
542 Some(s) => Some(s.parse().map_err(|e: ulid::DecodeError| {
543 rusqlite::Error::ToSqlConversionFailure(Box::new(e))
544 })?),
545 None => None,
546 },
547 name: row.get(2)?,
548 notes: row.get(3)?,
549 created_at_ms: row.get(4)?,
550 })
551}
552
553fn row_to_chart(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreResult<Chart>> {
554 // Doble-Result porque hay deserialización JSON adentro que rusqlite no
555 // sabe modelar. El caller la aplana.
556 let id_str: String = row.get(0)?;
557 let contact_str: String = row.get(1)?;
558 let kind_json: String = row.get(2)?;
559 let label: String = row.get(3)?;
560 let bd_json: String = row.get(4)?;
561 let cfg_json: String = row.get(5)?;
562 let related_str: Option<String> = row.get(6)?;
563 let created_at_ms: i64 = row.get(7)?;
564
565 Ok((|| -> StoreResult<Chart> {
566 Ok(Chart {
567 id: id_str.parse()?,
568 contact_id: contact_str.parse()?,
569 kind: serde_json::from_str(&kind_json)?,
570 label,
571 birth_data: serde_json::from_str(&bd_json)?,
572 config: serde_json::from_str(&cfg_json)?,
573 related_chart_id: match related_str {
574 Some(s) => Some(s.parse()?),
575 None => None,
576 },
577 created_at_ms,
578 })
579 })())
580}
581
582fn now_ms() -> i64 {
583 SystemTime::now()
584 .duration_since(UNIX_EPOCH)
585 .map(|d| d.as_millis() as i64)
586 .unwrap_or(0)
587}
588
589// =====================================================================
590// Tests
591// =====================================================================
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596 use tahuantinsuyu_model::{ModuleState, StoredBirthData, StoredChartConfig};
597
598 #[test]
599 fn open_and_migrate() {
600 let s = Store::in_memory().unwrap();
601 let groups = s.list_groups(None).unwrap();
602 assert!(groups.is_empty());
603 }
604
605 #[test]
606 fn module_state_roundtrip() {
607 let s = Store::in_memory().unwrap();
608 let g = s.create_group(None, "Familia", None).unwrap();
609 let c = s.create_contact(Some(g.id), "Sergio", None).unwrap();
610 let chart = s
611 .create_chart(
612 c.id,
613 ChartKind::Natal,
614 "Natal",
615 &StoredBirthData {
616 year: 1987,
617 month: 3,
618 day: 14,
619 hour: 5,
620 minute: 22,
621 second: 0.0,
622 tz_offset_minutes: -240,
623 latitude_deg: 10.4806,
624 longitude_deg: -66.9036,
625 altitude_m: 900.0,
626 time_certainty: Default::default(),
627 subject_name: None,
628 birthplace_label: None,
629 },
630 &StoredChartConfig::default(),
631 None,
632 )
633 .unwrap();
634
635 // Persistir dos módulos con configs distintos.
636 let state1 = ModuleState {
637 chart_id: chart.id,
638 module_id: "transit".into(),
639 enabled: true,
640 config: serde_json::json!({}),
641 };
642 let state2 = ModuleState {
643 chart_id: chart.id,
644 module_id: "progression".into(),
645 enabled: false,
646 config: serde_json::json!({ "target_age_years": 42.5 }),
647 };
648 s.upsert_module_state(&state1).unwrap();
649 s.upsert_module_state(&state2).unwrap();
650
651 let loaded = s.list_module_states(chart.id).unwrap();
652 assert_eq!(loaded.len(), 2);
653 let by_id: std::collections::HashMap<_, _> =
654 loaded.into_iter().map(|m| (m.module_id.clone(), m)).collect();
655 assert_eq!(by_id["transit"].enabled, true);
656 assert_eq!(by_id["progression"].enabled, false);
657 assert_eq!(
658 by_id["progression"]
659 .config
660 .get("target_age_years")
661 .and_then(|v| v.as_f64()),
662 Some(42.5)
663 );
664
665 // Upsert: cambiar enabled de transit a false.
666 let state1_off = ModuleState {
667 chart_id: chart.id,
668 module_id: "transit".into(),
669 enabled: false,
670 config: serde_json::json!({}),
671 };
672 s.upsert_module_state(&state1_off).unwrap();
673 let loaded = s.list_module_states(chart.id).unwrap();
674 let by_id: std::collections::HashMap<_, _> =
675 loaded.into_iter().map(|m| (m.module_id.clone(), m)).collect();
676 assert_eq!(by_id["transit"].enabled, false);
677 }
678
679 #[test]
680 fn full_hierarchy_roundtrip() {
681 let s = Store::in_memory().unwrap();
682 let g = s.create_group(None, "Familia", None).unwrap();
683 let c = s.create_contact(Some(g.id), "Sergio", None).unwrap();
684 let chart = s
685 .create_chart(
686 c.id,
687 ChartKind::Natal,
688 "Natal",
689 &StoredBirthData {
690 year: 1987,
691 month: 3,
692 day: 14,
693 hour: 5,
694 minute: 22,
695 second: 0.0,
696 tz_offset_minutes: -240,
697 latitude_deg: 10.4806,
698 longitude_deg: -66.9036,
699 altitude_m: 900.0,
700 time_certainty: Default::default(),
701 subject_name: Some("Sergio".into()),
702 birthplace_label: Some("Caracas".into()),
703 },
704 &StoredChartConfig::default(),
705 None,
706 )
707 .unwrap();
708 assert_eq!(s.get_chart(chart.id).unwrap().label, "Natal");
709
710 let under = s.charts_under_group(g.id).unwrap();
711 assert_eq!(under.len(), 1);
712 assert_eq!(under[0].id, chart.id);
713 }
714}