tawasuyu/nakui
> Excel-style reactive engine, on solid principles — exact Decimal, topological cascade, WAL, time-travel — in Rust, with a Llimphi UI.
⎇ 7041e0499f8449ae8d7bba2a65695d82cef05a9e
01_yachay/nakui/nakui-sheet/src/workbook.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat: nakui standalone — front-door ERP/Hoja/Grafo sobre Llimphi
1//! `Workbook` — `Sheet` con WAL persistente e invariantes.
2//!
3//! Cada `set_cell` ejecutado por el usuario se aplica sobre un
4//! candidato (`Sheet::clone`), se validan todos los invariantes
5//! declarados, y solo si todos pasan el cambio se promueve. Si algún
6//! invariante falla, el workbook queda EXACTAMENTE como estaba —
7//! "atomicidad de hoja", el principio del que se hablaba en el plan
8//! inicial.
9//!
10//! Esta capa es donde Nakui se diferencia del Excel tradicional:
11//! puedes declarar "el balance de caja nunca puede ser negativo" o
12//! "SUM(D:D) = K1" como reglas, y el motor las hace cumplir contra
13//! cada edición. No hay "fórmula rota y nadie se entera".
14//!
15//! El WAL aquí es local (Vec + JSONL). La integración con
16//! `nakui-core::event_log` (canonical, drift-detected, replay vía
17//! morfismos) es el siguiente bloque y vive como un trait que
18//! implementa este `Vec` y, en producción, el log durable.
19
20use crate::cell::{CellRange, CellRef};
21use crate::formula::{self, CellResolver, FormulaExpr};
22use crate::sheet::{SetError, SetReport, Sheet};
23use crate::sink::{EventSink, MemorySink, SinkError};
24use crate::value::{CellFormat, SheetValue};
25use serde::{Deserialize, Serialize};
26use std::io::{self, BufRead, Write};
27use thiserror::Error;
28
29#[derive(Debug, Error)]
30pub enum WorkbookError {
31 #[error(transparent)]
32 Set(#[from] SetError),
33 #[error("invariant `{name}` violated; edit reverted")]
34 InvariantViolated { name: String, value: SheetValue },
35 #[error("invariant parse error: {0}")]
36 InvariantParse(#[from] formula::ParseError),
37 #[error("io: {0}")]
38 Io(#[from] io::Error),
39 #[error("event log decode error at line {line}: {reason}")]
40 LogDecode { line: usize, reason: String },
41 #[error("event log refers to sequence numbers out of order")]
42 LogOutOfOrder,
43 #[error("sink error: {0}")]
44 Sink(#[from] SinkError),
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48#[serde(tag = "op", rename_all = "snake_case")]
49pub enum SheetEvent {
50 SetCell { cell: CellRef, raw: String },
51 ClearCell { cell: CellRef },
52 /// Fill desde una celda fuente a un rango destino. Se registra
53 /// como un solo evento (no como N SetCell) para que el replay
54 /// sea idéntico al gesto del usuario y el WAL ocupe menos.
55 Fill { src: CellRef, dest: CellRange },
56 /// Restauración atómica de varias celdas a un estado anterior.
57 /// Emitido por `undo` y por cualquier flujo que necesite revertir
58 /// un batch arbitrario. `None` en el raw significa "borrar esta
59 /// celda" (lo que hacía `ClearCell` para un solo elemento).
60 ///
61 /// Esto NO viola la inmutabilidad del WAL: Restore es un evento
62 /// más al final del log, no una mutación del pasado. El estado
63 /// del workbook tras un undo+redo es indistinguible del que
64 /// habría producido la edición original — pero el WAL conserva
65 /// el rastro completo de la operación.
66 Restore { cells: Vec<(CellRef, Option<String>)> },
67 /// Cambia el formato de display de una celda. NO toca el valor
68 /// (sigue siendo el mismo Decimal/Text/Bool), solo cómo se pinta.
69 SetFormat { cell: CellRef, format: CellFormat },
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
73pub struct RecordedEvent {
74 pub seq: u64,
75 /// Milisegundos desde Unix epoch. Sirve para time-travel por
76 /// reloj, pero el orden canónico es `seq` — un sistema que
77 /// rebobina el reloj no rompe el replay.
78 pub timestamp_ms: u128,
79 pub event: SheetEvent,
80}
81
82#[derive(Debug, Clone)]
83struct Invariant {
84 name: String,
85 expr: FormulaExpr,
86}
87
88pub struct Workbook {
89 sheet: Sheet,
90 /// Sink de eventos — la capa que decide si vive en RAM, en
91 /// disco, o en `nakui-core::event_log`. Default: [`MemorySink`].
92 sink: Box<dyn EventSink>,
93 /// Cache de los eventos para que `events()` siga devolviendo
94 /// `&[...]` sin tocar el sink (que sí podría hacer I/O).
95 events_cache: Vec<RecordedEvent>,
96 invariants: Vec<Invariant>,
97 /// Cursor virtual: cuántos eventos del cache están aplicados al
98 /// `sheet` actual. En operación normal `applied == events_cache.
99 /// len()`. Cuando hay un `undo` activo, `applied <
100 /// events_cache.len()`; los eventos por delante quedan para
101 /// `redo`. Una edición nueva cuando `applied < len()` trunca
102 /// los eventos por delante del sink (la historia alternativa
103 /// se pierde, semántica clásica de editor).
104 applied: usize,
105}
106
107impl std::fmt::Debug for Workbook {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 f.debug_struct("Workbook")
110 .field("sheet", &self.sheet)
111 .field("events", &self.events_cache.len())
112 .field("invariants", &self.invariants.len())
113 .finish()
114 }
115}
116
117impl Default for Workbook {
118 fn default() -> Self {
119 Self {
120 sheet: Sheet::default(),
121 sink: Box::new(MemorySink::new()),
122 events_cache: Vec::new(),
123 invariants: Vec::new(),
124 applied: 0,
125 }
126 }
127}
128
129impl Workbook {
130 pub fn new() -> Self {
131 Self::default()
132 }
133
134 /// Construye un workbook con un sink custom (file, custom, etc).
135 /// Si el sink trae eventos previos (FileSink leyendo un archivo
136 /// existente), se reaplican al sheet en orden para reconstruir
137 /// el estado.
138 pub fn with_sink(sink: Box<dyn EventSink>) -> Result<Self, WorkbookError> {
139 let mut sheet = Sheet::default();
140 let existing = sink.events();
141 for ev in &existing {
142 apply_to_sheet(&mut sheet, &ev.event)?;
143 }
144 let applied = existing.len();
145 Ok(Self {
146 sheet,
147 sink,
148 events_cache: existing,
149 invariants: Vec::new(),
150 applied,
151 })
152 }
153
154 pub fn sheet(&self) -> &Sheet {
155 &self.sheet
156 }
157
158 pub fn events(&self) -> &[RecordedEvent] {
159 &self.events_cache
160 }
161
162 pub fn value(&self, cr: CellRef) -> SheetValue {
163 self.sheet.value(cr)
164 }
165
166 pub fn raw(&self, cr: CellRef) -> Option<&str> {
167 self.sheet.raw(cr)
168 }
169
170 /// Declara un invariante que debe evaluar a `TRUE` tras cada
171 /// edición. La fórmula se compila una vez; el name es para
172 /// mensajes de error.
173 pub fn add_invariant(&mut self, name: &str, formula: &str) -> Result<(), WorkbookError> {
174 let expr = formula::compile(formula.strip_prefix('=').unwrap_or(formula))?;
175 self.invariants.push(Invariant {
176 name: name.to_string(),
177 expr,
178 });
179 Ok(())
180 }
181
182 /// Aplica un `set_cell` con validación atómica de invariantes.
183 /// Si cualquier invariante falla en el estado resultante, el
184 /// workbook queda intacto y se devuelve el error.
185 pub fn set_cell(&mut self, cr: CellRef, raw: &str) -> Result<SetReport, WorkbookError> {
186 let event = if raw.is_empty() {
187 SheetEvent::ClearCell { cell: cr }
188 } else {
189 SheetEvent::SetCell {
190 cell: cr,
191 raw: raw.to_string(),
192 }
193 };
194 self.apply_user_event(event)
195 }
196
197 pub fn clear_cell(&mut self, cr: CellRef) -> Result<SetReport, WorkbookError> {
198 self.apply_user_event(SheetEvent::ClearCell { cell: cr })
199 }
200
201 /// Replica la fórmula de `src` al rango `dest`, ajustando refs
202 /// relativas y respetando `$` (igual que el fill-handle de
203 /// Excel). El rango destino puede incluir o no a `src`; si lo
204 /// incluye, `src` se preserva intacto. Si una ref shifted se
205 /// sale de la hoja queda como `#REF!` en esa celda específica.
206 /// Atómico vs. invariantes: si tras el fill alguno se viola, se
207 /// revierte todo.
208 pub fn fill(&mut self, src: CellRef, dest: CellRange) -> Result<SetReport, WorkbookError> {
209 self.apply_user_event(SheetEvent::Fill { src, dest })
210 }
211
212 /// Copia `src` a `dest` con shift (igual que `fill` sobre un
213 /// rango de una sola celda).
214 pub fn copy_cell(&mut self, src: CellRef, dest: CellRef) -> Result<SetReport, WorkbookError> {
215 self.fill(src, CellRange::new(dest, dest))
216 }
217
218 /// Aplica un formato de display a una celda. Si tras el cambio
219 /// algún invariante se viola (extraño — los invariantes leen
220 /// valores, no formatos, pero podría haber edge cases), se
221 /// revierte.
222 pub fn set_format(
223 &mut self,
224 cell: CellRef,
225 format: CellFormat,
226 ) -> Result<SetReport, WorkbookError> {
227 self.apply_user_event(SheetEvent::SetFormat { cell, format })
228 }
229
230 /// Formato actual de la celda (`General` por defecto).
231 pub fn format(&self, cr: CellRef) -> CellFormat {
232 self.sheet.format(cr)
233 }
234
235 /// Display de la celda respetando su formato — lo que la UI
236 /// debe pintar. Para celdas sin formato custom es equivalente a
237 /// `value(cell).to_display_string()`.
238 pub fn formatted(&self, cr: CellRef) -> String {
239 self.sheet
240 .value(cr)
241 .to_formatted_string(&self.sheet.format(cr))
242 }
243
244 /// Deshace la última edición. Estrategia: cursor virtual.
245 /// El WAL no se modifica; sólo movemos `applied` hacia atrás y
246 /// reconstruimos el sheet desde el snapshot. Una edición nueva
247 /// estando en estado `applied < len()` trunca el "futuro
248 /// alternativo" tanto del cache como del sink.
249 pub fn undo(&mut self) -> Result<Option<SetReport>, WorkbookError> {
250 if self.applied == 0 {
251 return Ok(None);
252 }
253 let new_applied = self.applied - 1;
254 let new_sheet = self.snapshot_up_to(new_applied)?;
255 self.sheet = new_sheet;
256 self.applied = new_applied;
257 // Reporte vacío: no comparamos celda por celda. La UI
258 // sabe que tras undo todo cambió y va a repintar de
259 // todos modos.
260 Ok(Some(SetReport::default()))
261 }
262
263 /// Rehace el último undo, avanzando el cursor virtual una
264 /// posición y reaplicando el evento al sheet. Devuelve `None`
265 /// si no hay nada por rehacer.
266 pub fn redo(&mut self) -> Result<Option<SetReport>, WorkbookError> {
267 if self.applied >= self.events_cache.len() {
268 return Ok(None);
269 }
270 let event = self.events_cache[self.applied].event.clone();
271 let mut candidate = self.sheet.clone();
272 let report = apply_to_sheet(&mut candidate, &event)?;
273 Self::check_invariants(&self.invariants, &candidate)?;
274 self.sheet = candidate;
275 self.applied += 1;
276 Ok(Some(report))
277 }
278
279 pub fn can_undo(&self) -> bool {
280 self.applied > 0
281 }
282
283 pub fn can_redo(&self) -> bool {
284 self.applied < self.events_cache.len()
285 }
286
287 /// Cuántos eventos del cache están actualmente aplicados al
288 /// sheet. En estado normal == `events().len()`. Tras un undo
289 /// queda por debajo, dejando eventos disponibles para `redo`.
290 pub fn applied_count(&self) -> usize {
291 self.applied
292 }
293
294 /// Reconstruye un `Sheet` aplicando los primeros `n` eventos del
295 /// cache. Igual que `snapshot_at` pero sin acceso público — lo
296 /// usa `undo` para volver al estado anterior.
297 fn snapshot_up_to(&self, n: usize) -> Result<Sheet, WorkbookError> {
298 let mut s = Sheet::new();
299 for ev in self.events_cache.iter().take(n) {
300 apply_to_sheet(&mut s, &ev.event)?;
301 }
302 Ok(s)
303 }
304
305 /// Recalcula explícitamente las celdas volátiles (`TODAY`,
306 /// `NOW`, `RAND`, etc.). No se registra como evento en el WAL
307 /// — un refresh manual no cambia la historia editable, solo
308 /// "despierta" lo que es función del tiempo. Si tras el recalc
309 /// algún invariante se viola, se revierte y se devuelve el
310 /// error (igual que set_cell).
311 pub fn refresh_volatiles(&mut self) -> Result<SetReport, WorkbookError> {
312 let mut candidate = self.sheet.clone();
313 let report = candidate.recompute_volatiles();
314 Self::check_invariants(&self.invariants, &candidate)?;
315 self.sheet = candidate;
316 Ok(report)
317 }
318
319 pub fn volatile_count(&self) -> usize {
320 self.sheet.volatile_count()
321 }
322
323 fn apply_user_event(&mut self, event: SheetEvent) -> Result<SetReport, WorkbookError> {
324 // Si hay redos pendientes (applied < len), los descartamos:
325 // el "futuro alternativo" se pierde al editar. Truncamos
326 // tanto el cache local como el sink (filesystem/memory).
327 if self.applied < self.events_cache.len() {
328 let truncate_from_seq = self
329 .events_cache
330 .get(self.applied)
331 .map(|e| e.seq)
332 .unwrap_or(0);
333 self.sink.truncate_from(truncate_from_seq)?;
334 self.events_cache.truncate(self.applied);
335 }
336
337 let mut candidate = self.sheet.clone();
338 let report = apply_to_sheet(&mut candidate, &event)?;
339 Self::check_invariants(&self.invariants, &candidate)?;
340 self.sheet = candidate;
341 let timestamp_ms = now_ms();
342 let seq = self.sink.record(event.clone(), timestamp_ms)?;
343 self.events_cache.push(RecordedEvent {
344 seq,
345 timestamp_ms,
346 event,
347 });
348 self.applied = self.events_cache.len();
349 Ok(report)
350 }
351
352 fn check_invariants(invariants: &[Invariant], sheet: &Sheet) -> Result<(), WorkbookError> {
353 let resolver = SheetResolver { sheet };
354 for inv in invariants {
355 let value = formula::eval_formula(&inv.expr, &resolver);
356 let ok = matches!(value, SheetValue::Bool(true));
357 if !ok {
358 return Err(WorkbookError::InvariantViolated {
359 name: inv.name.clone(),
360 value,
361 });
362 }
363 }
364 Ok(())
365 }
366
367 /// Serializa los eventos como JSONL — una línea por evento. El
368 /// formato es estable: misma versión de Nakui produce el mismo
369 /// bytes-for-bytes, lo cual es lo que permite verificar drift.
370 pub fn write_log<W: Write>(&self, mut w: W) -> Result<(), WorkbookError> {
371 for ev in &self.events_cache {
372 serde_json::to_writer(&mut w, ev).map_err(|e| {
373 WorkbookError::LogDecode {
374 line: ev.seq as usize,
375 reason: e.to_string(),
376 }
377 })?;
378 w.write_all(b"\n")?;
379 }
380 Ok(())
381 }
382
383 /// Reconstruye un workbook desde un log JSONL. Reaplica cada
384 /// evento en orden de `seq` (debe ser estrictamente creciente
385 /// desde 0). No reaplica invariantes — el log es la fuente de
386 /// verdad de lo que ocurrió, y si fuera inconsistente lo
387 /// detectaríamos al evaluar.
388 pub fn from_log<R: BufRead>(r: R) -> Result<Self, WorkbookError> {
389 let sink = MemorySink::from_reader(r)?;
390 Self::with_sink(Box::new(sink))
391 }
392
393 /// Time-travel: reconstruye la hoja como estaba después de
394 /// procesar los primeros `n` eventos (`n=0` → hoja vacía;
395 /// `n=events.len()` → hoja actual). El workbook actual no se
396 /// modifica — devolvemos un `Sheet` snapshot.
397 pub fn snapshot_at(&self, n: usize) -> Result<Sheet, WorkbookError> {
398 let mut s = Sheet::new();
399 for ev in self.events_cache.iter().take(n) {
400 apply_to_sheet(&mut s, &ev.event)?;
401 }
402 Ok(s)
403 }
404}
405
406/// Aplica un `SheetEvent` directamente a un `Sheet`. Reusada por
407/// `apply_user_event` (sobre el candidato) y por el replay del log.
408fn apply_to_sheet(sheet: &mut Sheet, event: &SheetEvent) -> Result<SetReport, SetError> {
409 match event {
410 SheetEvent::SetCell { cell, raw } => sheet.set_cell(*cell, raw),
411 SheetEvent::ClearCell { cell } => Ok(sheet.clear_cell(*cell)),
412 SheetEvent::Fill { src, dest } => apply_fill(sheet, *src, *dest),
413 SheetEvent::Restore { cells } => apply_restore(sheet, cells),
414 SheetEvent::SetFormat { cell, format } => {
415 sheet.set_format(*cell, format.clone());
416 // SetFormat no dispara cascada de cómputo (es metadata),
417 // pero igual reportamos la celda como "tocada" para que la
418 // UI sepa que tiene que repintar.
419 let mut rep = SetReport::default();
420 rep.changed
421 .push((*cell, sheet.value(*cell), sheet.value(*cell)));
422 Ok(rep)
423 }
424 }
425}
426
427/// Aplica un batch de restauración. Acumula los SetReport individuales
428/// para que el caller vea TODAS las celdas que cambiaron.
429fn apply_restore(
430 sheet: &mut Sheet,
431 cells: &[(CellRef, Option<String>)],
432) -> Result<SetReport, SetError> {
433 let mut combined = SetReport::default();
434 for (cr, raw) in cells {
435 let rep = match raw {
436 Some(s) => sheet.set_cell(*cr, s)?,
437 None => sheet.clear_cell(*cr),
438 };
439 combined.changed.extend(rep.changed);
440 }
441 Ok(combined)
442}
443
444/// Implementación del fill: lee la celda fuente, shifta su expr por
445/// cada celda destino, persiste el resultado. Se incluye `src` en
446/// `dest` solo si dest lo incluye; si no, el src queda intacto.
447///
448/// Atomicidad: aplicamos uno a uno con `set_cell_expr`. Si una de las
449/// celdas destino cierra un ciclo (caso raro pero posible si la
450/// fórmula se auto-referencia tras shiftar), la celda específica
451/// queda con su valor anterior — las demás siguen aplicándose. La
452/// transacción más amplia (vs. invariantes) la maneja `Workbook`
453/// arriba con candidate-swap.
454fn apply_fill(sheet: &mut Sheet, src: CellRef, dest: CellRange) -> Result<SetReport, SetError> {
455 let src_state = match sheet.cells_get(src) {
456 Some(s) => s,
457 None => {
458 // Sin fuente no hay qué replicar; reporte vacío.
459 return Ok(SetReport::default());
460 }
461 };
462 let src_expr = src_state.expr.clone();
463 let src_raw = src_state.raw.clone();
464 let mut combined = SetReport::default();
465 for target in dest.iter() {
466 if target == src {
467 continue;
468 }
469 let drow = target.row as i32 - src.row as i32;
470 let dcol = target.col as i32 - src.col as i32;
471 let shifted = formula::shift(&src_expr, drow, dcol);
472 let new_raw = build_raw(&src_raw, &shifted);
473 match sheet.set_cell_expr(target, shifted, new_raw) {
474 Ok(rep) => combined.changed.extend(rep.changed),
475 Err(SetError::Cycle(_)) => {
476 // El shift creó un ciclo en esta celda (raro). La
477 // saltamos y seguimos — no rompemos el fill entero.
478 }
479 Err(SetError::Parse(_)) => unreachable!("expr ya parseada"),
480 }
481 }
482 Ok(combined)
483}
484
485/// Reconstruye el raw a partir del expr shifted. Mantiene el prefijo
486/// `=` solo si el raw original lo tenía (literales no llevan `=`).
487fn build_raw(orig_raw: &str, expr: &FormulaExpr) -> String {
488 let rendered = formula::render(expr);
489 if orig_raw.starts_with('=') {
490 format!("={rendered}")
491 } else {
492 rendered
493 }
494}
495
496struct SheetResolver<'a> {
497 sheet: &'a Sheet,
498}
499
500impl<'a> CellResolver for SheetResolver<'a> {
501 fn resolve(&self, cell: CellRef) -> SheetValue {
502 self.sheet.value(cell)
503 }
504}
505
506fn now_ms() -> u128 {
507 std::time::SystemTime::now()
508 .duration_since(std::time::UNIX_EPOCH)
509 .map(|d| d.as_millis())
510 .unwrap_or(0)
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use crate::cell::CellRef;
517 use crate::sink::FileSink;
518 use crate::value::SheetError;
519 use rust_decimal::Decimal;
520 use std::io::Cursor;
521 use std::str::FromStr;
522
523 fn cr(s: &str) -> CellRef {
524 s.parse().unwrap()
525 }
526 fn dec(s: &str) -> Decimal {
527 Decimal::from_str(s).unwrap()
528 }
529
530 #[test]
531 fn events_record_in_order() {
532 let mut wb = Workbook::new();
533 wb.set_cell(cr("A1"), "1").unwrap();
534 wb.set_cell(cr("B1"), "=A1+10").unwrap();
535 wb.set_cell(cr("A1"), "5").unwrap();
536 assert_eq!(wb.events().len(), 3);
537 for (i, ev) in wb.events().iter().enumerate() {
538 assert_eq!(ev.seq, i as u64);
539 }
540 }
541
542 #[test]
543 fn replay_reconstructs_state() {
544 let mut wb = Workbook::new();
545 wb.set_cell(cr("A1"), "10").unwrap();
546 wb.set_cell(cr("B1"), "=A1*3").unwrap();
547 wb.set_cell(cr("A1"), "7").unwrap();
548
549 let mut buf = Vec::new();
550 wb.write_log(&mut buf).unwrap();
551 let wb2 = Workbook::from_log(Cursor::new(buf)).unwrap();
552 assert_eq!(wb2.value(cr("A1")), SheetValue::Number(dec("7")));
553 assert_eq!(wb2.value(cr("B1")), SheetValue::Number(dec("21")));
554 }
555
556 #[test]
557 fn snapshot_at_walks_history() {
558 let mut wb = Workbook::new();
559 wb.set_cell(cr("A1"), "1").unwrap(); // seq 0
560 wb.set_cell(cr("A1"), "2").unwrap(); // seq 1
561 wb.set_cell(cr("A1"), "3").unwrap(); // seq 2
562
563 assert_eq!(wb.snapshot_at(0).unwrap().value(cr("A1")), SheetValue::Empty);
564 assert_eq!(
565 wb.snapshot_at(1).unwrap().value(cr("A1")),
566 SheetValue::Number(dec("1"))
567 );
568 assert_eq!(
569 wb.snapshot_at(2).unwrap().value(cr("A1")),
570 SheetValue::Number(dec("2"))
571 );
572 assert_eq!(
573 wb.snapshot_at(3).unwrap().value(cr("A1")),
574 SheetValue::Number(dec("3"))
575 );
576 }
577
578 #[test]
579 fn invariant_blocks_violating_edit() {
580 let mut wb = Workbook::new();
581 wb.set_cell(cr("A1"), "100").unwrap();
582 // Regla: saldo (A1) jamás negativo.
583 wb.add_invariant("saldo_no_negativo", "=A1>=0").unwrap();
584 // Edición OK: A1 = 50.
585 wb.set_cell(cr("A1"), "50").unwrap();
586 assert_eq!(wb.value(cr("A1")), SheetValue::Number(dec("50")));
587 // Edición prohibida: A1 = -10. Debe rechazarse.
588 let err = wb.set_cell(cr("A1"), "-10").unwrap_err();
589 assert!(matches!(err, WorkbookError::InvariantViolated { .. }));
590 // El workbook quedó en el estado anterior intacto.
591 assert_eq!(wb.value(cr("A1")), SheetValue::Number(dec("50")));
592 // Y el evento NO se registró en el log.
593 assert_eq!(wb.events().len(), 2);
594 }
595
596 #[test]
597 fn invariant_evaluates_downstream_sum() {
598 let mut wb = Workbook::new();
599 wb.set_cell(cr("A1"), "10").unwrap();
600 wb.set_cell(cr("A2"), "20").unwrap();
601 wb.set_cell(cr("A3"), "30").unwrap();
602 wb.set_cell(cr("B1"), "=SUM(A1:A3)").unwrap();
603 // Regla: el total nunca > 100.
604 wb.add_invariant("tope_total", "=B1<=100").unwrap();
605 // Permitido: total 70.
606 wb.set_cell(cr("A3"), "40").unwrap();
607 assert_eq!(wb.value(cr("B1")), SheetValue::Number(dec("70")));
608 // Prohibido: total 130.
609 assert!(wb.set_cell(cr("A2"), "80").is_err());
610 // El total sigue siendo 70.
611 assert_eq!(wb.value(cr("B1")), SheetValue::Number(dec("70")));
612 }
613
614 #[test]
615 fn cycle_error_propagates_through_workbook() {
616 let mut wb = Workbook::new();
617 wb.set_cell(cr("A1"), "=B1+1").unwrap();
618 let err = wb.set_cell(cr("B1"), "=A1+1").unwrap_err();
619 assert!(matches!(err, WorkbookError::Set(SetError::Cycle(_))));
620 }
621
622 #[test]
623 fn fill_replicates_formula_shifting_refs() {
624 let mut wb = Workbook::new();
625 // Columna A con cantidades.
626 wb.set_cell(cr("A1"), "10").unwrap();
627 wb.set_cell(cr("A2"), "20").unwrap();
628 wb.set_cell(cr("A3"), "30").unwrap();
629 // B1 = A1 * 2. Fill hasta B3.
630 wb.set_cell(cr("B1"), "=A1*2").unwrap();
631 wb.fill(cr("B1"), "B1:B3".parse().unwrap()).unwrap();
632 assert_eq!(wb.value(cr("B1")), SheetValue::Number(dec("20")));
633 assert_eq!(wb.value(cr("B2")), SheetValue::Number(dec("40")));
634 assert_eq!(wb.value(cr("B3")), SheetValue::Number(dec("60")));
635 // El raw de B2 debe reflejar el shift.
636 assert_eq!(wb.raw(cr("B2")), Some("=A2*2"));
637 }
638
639 #[test]
640 fn fill_respects_dollar_anchors() {
641 let mut wb = Workbook::new();
642 wb.set_cell(cr("A1"), "10").unwrap();
643 wb.set_cell(cr("A2"), "20").unwrap();
644 wb.set_cell(cr("A3"), "30").unwrap();
645 wb.set_cell(cr("C1"), "100").unwrap(); // factor anclado
646 // B1 = A1 * $C$1
647 wb.set_cell(cr("B1"), "=A1*$C$1").unwrap();
648 wb.fill(cr("B1"), "B1:B3".parse().unwrap()).unwrap();
649 assert_eq!(wb.value(cr("B1")), SheetValue::Number(dec("1000")));
650 assert_eq!(wb.value(cr("B2")), SheetValue::Number(dec("2000")));
651 // Verifico que $C$1 no se shifteó.
652 assert_eq!(wb.raw(cr("B3")), Some("=A3*$C$1"));
653 }
654
655 #[test]
656 fn fill_out_of_sheet_produces_ref_error() {
657 let mut wb = Workbook::new();
658 wb.set_cell(cr("B2"), "=A2*2").unwrap();
659 // Fill hacia A2 (drow=0, dcol=-1) → A2 referenciaría col -1 → #REF!
660 wb.fill(cr("B2"), "A2:A2".parse().unwrap()).unwrap();
661 assert_eq!(wb.value(cr("A2")), SheetValue::Error(SheetError::Ref));
662 }
663
664 #[test]
665 fn fill_preserves_src_when_dest_includes_it() {
666 let mut wb = Workbook::new();
667 wb.set_cell(cr("A1"), "5").unwrap();
668 wb.set_cell(cr("B1"), "=A1*10").unwrap();
669 // Fill B1:B3 con B1 dentro del rango: B1 no debe modificarse.
670 let before_raw = wb.raw(cr("B1")).unwrap().to_string();
671 wb.fill(cr("B1"), "B1:B3".parse().unwrap()).unwrap();
672 assert_eq!(wb.raw(cr("B1")).unwrap(), before_raw);
673 }
674
675 #[test]
676 fn copy_cell_is_fill_of_singleton() {
677 let mut wb = Workbook::new();
678 wb.set_cell(cr("A1"), "7").unwrap();
679 wb.set_cell(cr("A2"), "11").unwrap();
680 wb.set_cell(cr("B1"), "=A1+1").unwrap();
681 wb.copy_cell(cr("B1"), cr("B2")).unwrap();
682 assert_eq!(wb.value(cr("B2")), SheetValue::Number(dec("12")));
683 assert_eq!(wb.raw(cr("B2")), Some("=A2+1"));
684 }
685
686 #[test]
687 fn volatile_count_tracks_today_cells() {
688 let mut wb = Workbook::new();
689 wb.set_cell(cr("A1"), "=TODAY()").unwrap();
690 wb.set_cell(cr("A2"), "=RAND()").unwrap();
691 wb.set_cell(cr("A3"), "=A1+1").unwrap(); // no es volátil ella misma
692 assert_eq!(wb.volatile_count(), 2);
693 // Reescribir A1 como literal saca la celda del set volátil.
694 wb.set_cell(cr("A1"), "42").unwrap();
695 assert_eq!(wb.volatile_count(), 1);
696 }
697
698 #[test]
699 fn refresh_volatiles_updates_rand_value() {
700 let mut wb = Workbook::new();
701 wb.set_cell(cr("A1"), "=RAND()").unwrap();
702 let v1 = wb.value(cr("A1"));
703 wb.refresh_volatiles().unwrap();
704 let v2 = wb.value(cr("A1"));
705 // Con PRNG y nanos del reloj, prácticamente seguro que
706 // cambia. Si por mala suerte coincide en un test único,
707 // sigue siendo un Number — el test no se vuelve flaky por
708 // valor, sino por shape.
709 match (v1, v2) {
710 (SheetValue::Number(_), SheetValue::Number(_)) => {}
711 other => panic!("rand no devolvió Number: {other:?}"),
712 }
713 }
714
715 #[test]
716 fn editing_unrelated_cell_recomputes_volatiles() {
717 let mut wb = Workbook::new();
718 wb.set_cell(cr("A1"), "=TODAY()").unwrap();
719 let initial = wb.value(cr("A1"));
720 // Editar B1 (sin dependencia con A1) debe re-evaluar A1.
721 // No comprobamos cambio de valor (el día casi nunca cambia
722 // entre dos llamadas), pero sí que A1 figure en el report.
723 let report = wb.set_cell(cr("B1"), "999").unwrap();
724 let touched: std::collections::HashSet<_> =
725 report.changed.iter().map(|(c, _, _)| *c).collect();
726 // B1 sí cambió de seguro. A1 puede no aparecer si el TODAY no
727 // cambió de valor — eso significa que recompute_volatiles ya
728 // se llamó pero el delta fue cero. Esa es la semántica que
729 // queremos.
730 assert!(touched.contains(&cr("B1")));
731 // Si quería A1 en el report siempre, tendría que cambiar la
732 // semántica de SetReport. Lo que sí garantizo: el valor
733 // sigue siendo un Number (no se quedó Empty por accidente).
734 match wb.value(cr("A1")) {
735 SheetValue::Number(_) => {}
736 other => panic!("A1 perdió su valor: {other:?}"),
737 }
738 let _ = initial;
739 }
740
741 #[test]
742 fn now_includes_subsecond_fraction() {
743 let mut wb = Workbook::new();
744 wb.set_cell(cr("A1"), "=NOW()").unwrap();
745 match wb.value(cr("A1")) {
746 SheetValue::Number(n) => {
747 // El test corre años 2026+ → serial > 20000.
748 assert!(n > dec("20000"));
749 }
750 other => panic!("NOW() no fue Number: {other:?}"),
751 }
752 }
753
754 #[test]
755 fn randbetween_in_range() {
756 let mut wb = Workbook::new();
757 wb.set_cell(cr("A1"), "=RANDBETWEEN(1, 6)").unwrap();
758 for _ in 0..20 {
759 wb.refresh_volatiles().unwrap();
760 match wb.value(cr("A1")) {
761 SheetValue::Number(n) => {
762 assert!(n >= dec("1") && n <= dec("6"), "out of range: {n}");
763 assert_eq!(n.fract(), Decimal::ZERO);
764 }
765 other => panic!("RANDBETWEEN no devolvió Number: {other:?}"),
766 }
767 }
768 }
769
770 #[test]
771 fn workbook_with_file_sink_round_trip() {
772 // Sesión 1: edito unas celdas y dejo el archivo cerrado.
773 let mut p = std::env::temp_dir();
774 let pid = std::process::id();
775 let nanos = std::time::SystemTime::now()
776 .duration_since(std::time::UNIX_EPOCH)
777 .map(|d| d.as_nanos())
778 .unwrap_or(0);
779 p.push(format!("nakui-wb-roundtrip-{pid}-{nanos}.jsonl"));
780
781 {
782 let sink = Box::new(FileSink::open(&p).unwrap());
783 let mut wb = Workbook::with_sink(sink).unwrap();
784 wb.set_cell(cr("A1"), "10").unwrap();
785 wb.set_cell(cr("B1"), "=A1*5").unwrap();
786 assert_eq!(wb.value(cr("B1")), SheetValue::Number(dec("50")));
787 }
788
789 // Sesión 2: vuelvo a abrir el mismo archivo y el estado
790 // debe reaparecer intacto.
791 {
792 let sink = Box::new(FileSink::open(&p).unwrap());
793 let wb = Workbook::with_sink(sink).unwrap();
794 assert_eq!(wb.value(cr("A1")), SheetValue::Number(dec("10")));
795 assert_eq!(wb.value(cr("B1")), SheetValue::Number(dec("50")));
796 assert_eq!(wb.events().len(), 2);
797 }
798 let _ = std::fs::remove_file(&p);
799 }
800
801 #[test]
802 fn undo_reverts_last_edit() {
803 let mut wb = Workbook::new();
804 wb.set_cell(cr("A1"), "10").unwrap();
805 wb.set_cell(cr("A1"), "20").unwrap();
806 assert_eq!(wb.value(cr("A1")), SheetValue::Number(dec("20")));
807 assert!(wb.can_undo());
808 wb.undo().unwrap();
809 assert_eq!(wb.value(cr("A1")), SheetValue::Number(dec("10")));
810 wb.undo().unwrap();
811 assert_eq!(wb.value(cr("A1")), SheetValue::Empty);
812 }
813
814 #[test]
815 fn undo_propagates_to_downstream() {
816 let mut wb = Workbook::new();
817 wb.set_cell(cr("A1"), "10").unwrap();
818 wb.set_cell(cr("B1"), "=A1*5").unwrap();
819 wb.set_cell(cr("A1"), "100").unwrap();
820 // B1 = 500.
821 assert_eq!(wb.value(cr("B1")), SheetValue::Number(dec("500")));
822 wb.undo().unwrap();
823 // A1 vuelve a 10, B1 cascada → 50.
824 assert_eq!(wb.value(cr("A1")), SheetValue::Number(dec("10")));
825 assert_eq!(wb.value(cr("B1")), SheetValue::Number(dec("50")));
826 }
827
828 #[test]
829 fn redo_replays_after_undo() {
830 let mut wb = Workbook::new();
831 wb.set_cell(cr("A1"), "10").unwrap();
832 wb.set_cell(cr("A1"), "20").unwrap();
833 wb.undo().unwrap();
834 assert_eq!(wb.value(cr("A1")), SheetValue::Number(dec("10")));
835 assert!(wb.can_redo());
836 wb.redo().unwrap();
837 assert_eq!(wb.value(cr("A1")), SheetValue::Number(dec("20")));
838 }
839
840 #[test]
841 fn editing_clears_redo_stack() {
842 let mut wb = Workbook::new();
843 wb.set_cell(cr("A1"), "10").unwrap();
844 wb.set_cell(cr("A1"), "20").unwrap();
845 wb.undo().unwrap();
846 assert!(wb.can_redo());
847 // Edición nueva tras undo → redo se pierde.
848 wb.set_cell(cr("A1"), "999").unwrap();
849 assert!(!wb.can_redo());
850 }
851
852 #[test]
853 fn undo_on_empty_workbook_is_noop() {
854 let mut wb = Workbook::new();
855 assert!(!wb.can_undo());
856 assert!(wb.undo().unwrap().is_none());
857 }
858
859 #[test]
860 fn undo_of_fill_restores_all_destination_cells() {
861 let mut wb = Workbook::new();
862 wb.set_cell(cr("A1"), "1").unwrap();
863 wb.set_cell(cr("A2"), "2").unwrap();
864 wb.set_cell(cr("A3"), "3").unwrap();
865 // B2 y B3 ya tienen contenido previo distinto.
866 wb.set_cell(cr("B2"), "OLD2").unwrap();
867 wb.set_cell(cr("B3"), "OLD3").unwrap();
868 wb.set_cell(cr("B1"), "=A1*10").unwrap();
869 // Fill: B1 ya está; B2 y B3 reciben =A2*10 y =A3*10.
870 wb.fill(cr("B1"), "B1:B3".parse().unwrap()).unwrap();
871 assert_eq!(wb.value(cr("B2")), SheetValue::Number(dec("20")));
872 assert_eq!(wb.value(cr("B3")), SheetValue::Number(dec("30")));
873 // Undo: B2 y B3 vuelven a OLD2 y OLD3.
874 wb.undo().unwrap();
875 assert_eq!(wb.value(cr("B2")), SheetValue::Text("OLD2".into()));
876 assert_eq!(wb.value(cr("B3")), SheetValue::Text("OLD3".into()));
877 // B1 NO se toca (no estaba en el write-set del Fill,
878 // excluida por ser la fuente).
879 assert_eq!(wb.value(cr("B1")), SheetValue::Number(dec("10")));
880 }
881
882 #[test]
883 fn set_format_changes_display_not_value() {
884 let mut wb = Workbook::new();
885 wb.set_cell(cr("A1"), "1234.5").unwrap();
886 // Sin formato: usa el natural.
887 assert_eq!(wb.formatted(cr("A1")), "1234.5");
888 wb.set_format(
889 cr("A1"),
890 CellFormat::Currency {
891 symbol: "$".into(),
892 decimals: 2,
893 },
894 )
895 .unwrap();
896 // El valor sigue siendo el mismo Decimal:
897 assert_eq!(wb.value(cr("A1")), SheetValue::Number(dec("1234.5")));
898 // ...pero el display cambió:
899 assert_eq!(wb.formatted(cr("A1")), "$1,234.50");
900 }
901
902 #[test]
903 fn format_persists_through_edits() {
904 let mut wb = Workbook::new();
905 wb.set_cell(cr("A1"), "10").unwrap();
906 wb.set_format(cr("A1"), CellFormat::Percent { decimals: 0 })
907 .unwrap();
908 // Reescribimos el valor. El formato debe sobrevivir.
909 wb.set_cell(cr("A1"), "0.25").unwrap();
910 assert_eq!(wb.format(cr("A1")), CellFormat::Percent { decimals: 0 });
911 assert_eq!(wb.formatted(cr("A1")), "25%");
912 }
913
914 #[test]
915 fn undo_undo_redo_redo_round_trip() {
916 let mut wb = Workbook::new();
917 wb.set_cell(cr("A1"), "1").unwrap();
918 wb.set_cell(cr("A1"), "2").unwrap();
919 wb.set_cell(cr("A1"), "3").unwrap();
920 wb.undo().unwrap();
921 wb.undo().unwrap();
922 assert_eq!(wb.value(cr("A1")), SheetValue::Number(dec("1")));
923 wb.redo().unwrap();
924 wb.redo().unwrap();
925 assert_eq!(wb.value(cr("A1")), SheetValue::Number(dec("3")));
926 }
927
928 #[test]
929 fn out_of_order_log_rejected() {
930 let mut wb = Workbook::new();
931 wb.set_cell(cr("A1"), "1").unwrap();
932 wb.set_cell(cr("A1"), "2").unwrap();
933 // Manipulación maliciosa del log: invertimos los eventos.
934 let mut wb2_events = wb.events().to_vec();
935 wb2_events.reverse();
936 let mut buf = Vec::new();
937 for ev in &wb2_events {
938 serde_json::to_writer(&mut buf, ev).unwrap();
939 buf.push(b'\n');
940 }
941 let err = Workbook::from_log(Cursor::new(buf)).unwrap_err();
942 // Tras refactorizar a EventSink, el out-of-order lo detecta
943 // MemorySink::from_reader → WorkbookError::Sink(Skew{..}).
944 assert!(
945 matches!(err, WorkbookError::Sink(SinkError::Skew { .. })),
946 "got: {err:?}"
947 );
948 }
949}