P3: the books' format moves only with the owner's go-ahead (D-39)

Books an update could write a newer format for open on their old format
and are asked at every open; anything needing a new feature is refused
until the owner agrees with the passphrase, after a verified backup that
includes the keys. New books start on the current format. Each projection
schema has its own database file, so an older version still opens the
books until the owner agrees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-21 14:04:11 -06:00
1 parent b0a9fd8ad4
commit 3a9aa92315
62 files changed
+5309 -628

No files matched your search

+1
View File
@@ -29,6 +29,7 @@ chrono = "0.4"
rust_decimal = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
zeroize = "1"
[dev-dependencies]
chrono = "0.4"
+3
View File
@@ -1083,6 +1083,9 @@ impl eframe::App for App {
});
return;
};
// Books waiting for a format upgrade: said above every page
// until it is done (owner.rs INVARIANT 5).
self.owner_menu.notice(ui, &books);
let mut shell = Shell {
receipts: &mut self.receipts,
state: &mut self.state,
+4
View File
@@ -324,6 +324,9 @@ pub struct Books {
pub statements: Vec<Statement>,
/// Reconciliations, voided ones included, by date then id.
pub reconciliations: Vec<Reconciliation>,
/// The format upgrade the books wait for, if they do (plan 3.9, D-39):
/// read from their journal, as the server reads it.
pub format_upgrade: Option<ledger_store::FormatUpgrade>,
}
/// A span of dates a party's money is summed over (the party page's and
@@ -838,6 +841,7 @@ impl Books {
links,
statements,
reconciliations,
format_upgrade: snap.journal_format().upgrade(),
};
books.parties = books.party_infos();
books.kpis = books.compute_kpis();
+241 -11
View File
@@ -1,7 +1,8 @@
//! The viewer's Books menu (plan P2 "Viewer menu", D-19, D-32): the
//! owner's own actions on the books, handed to the books server (back up
//! now, restore, new books, close the books) or to the owner's agent
//! (connect). The viewer itself still never writes the books.
//! now, restore, new books, close the books, the format upgrade) or to the
//! owner's agent (connect). The viewer itself still never writes the
//! books.
//!
//! ```text
//! GOAL: the owner backs up, restores, starts new books, closes the books
@@ -10,7 +11,9 @@
//! "New books…", "Close the books", "Connect Claude Code" and
//! "Connect Codex"; "Nothing in the viewer writes a book"); D-32
//! (no confinement: the viewer asks the server itself, as the
//! command line does).
//! command line does); plan 3.9 and D-39 (the format upgrade: the
//! go-ahead asked at every open until given, with the owner
//! passphrase, and a standing notice).
//! INVARIANTS:
//! 1. Every action goes through the [`Owner`] the program gives the
//! viewer (the books server, or the agent's own command): nothing
@@ -20,6 +23,12 @@
//! would do), and done only for that backup, at that head.
//! 4. Without an owner (the viewer on its own, older books dirs, the
//! demo) the menu isn't there.
//! 5. Books waiting for a format upgrade show a standing notice above
//! every page, and the go-ahead is asked once each time the viewer
//! opens them: the owner passphrase, typed here and handed to the
//! owner's `upgrade_format` (the server checks it; nothing here
//! does). "Not now" changes nothing; a refusal (a wrong passphrase)
//! is said in the dialog, which stays.
//! ```
use crate::books::Books;
@@ -83,6 +92,9 @@ pub trait Owner: Send + Sync {
fn restore(&self, plan: &RestorePlan) -> Result<Vec<String>, String>;
/// Let `agent` reach the books `book`.
fn connect(&self, agent: Agent, book: &str) -> Result<Vec<String>, String>;
/// The owner's go-ahead for the books `book`'s format upgrade, with the
/// owner passphrase they typed (the server checks it).
fn upgrade_format(&self, book: &str, passphrase: &str) -> Result<Vec<String>, String>;
}
static OWNER: OnceLock<Arc<dyn Owner>> = OnceLock::new();
@@ -117,6 +129,48 @@ enum Dialog {
Restore(Box<RestoreForm>),
/// "Close the books": asked first.
Close,
/// The format upgrade: the go-ahead, with the owner passphrase.
Upgrade(Box<UpgradeForm>),
}
/// The format upgrade's dialog (INVARIANT 5).
struct UpgradeForm {
/// What the books wait for.
upgrade: ledger_store::FormatUpgrade,
/// The owner passphrase, as typed (cleared once handed over).
passphrase: String,
/// The upgrade under way.
working: Option<Receiver<Outcome>>,
/// Why the last try was refused.
problem: Option<String>,
}
impl UpgradeForm {
fn new(upgrade: ledger_store::FormatUpgrade) -> Self {
Self {
upgrade,
passphrase: String::new(),
working: None,
problem: None,
}
}
}
impl Drop for UpgradeForm {
/// What was typed and not handed over is wiped from memory, as the
/// command line wipes what is typed at a terminal.
fn drop(&mut self) {
zeroize::Zeroize::zeroize(&mut self.passphrase);
}
}
/// Wakes the window when dropped: a worker's last act, however it ends.
struct Wake(egui::Context);
impl Drop for Wake {
fn drop(&mut self) {
self.0.request_repaint();
}
}
/// A restore's check under way, and what it was asked: the backup, the
@@ -147,21 +201,90 @@ pub struct OwnerMenu {
dialog: Dialog,
job: Option<(String, Receiver<Outcome>)>,
done: Option<(String, Outcome)>,
/// The books whose format upgrade was asked for since the viewer
/// opened them (INVARIANT 5: once each open).
asked_upgrade: Option<String>,
}
impl OwnerMenu {
/// The id of the books shown, if they are books of a data root: found
/// once for each load.
fn book_of(&mut self, books: &Arc<Books>) -> Option<String> {
let key = Arc::as_ptr(books) as usize;
if self.book.as_ref().is_none_or(|(k, _)| *k != key) {
self.book = Some((key, book_id(books)));
}
self.book.as_ref().and_then(|(_, id)| id.clone())
}
/// The standing notice of a format upgrade the books wait for, above
/// the page, and the go-ahead asked for once each open (INVARIANT 5).
pub fn notice(&mut self, ui: &mut Ui, books: &Arc<Books>) {
if owner().is_none() {
return;
}
let Some(book) = self.book_of(books) else {
return;
};
let Some(upgrade) = books.format_upgrade.clone() else {
return;
};
if self.asked_upgrade.as_deref() != Some(book.as_str())
&& matches!(self.dialog, Dialog::None)
&& self.job.is_none()
{
self.asked_upgrade = Some(book);
self.dialog = Dialog::Upgrade(Box::new(UpgradeForm::new(upgrade.clone())));
}
let p = crate::theme::pal(ui.ctx());
egui::Frame::new()
.fill(p.warning_soft)
.inner_margin(egui::Margin::symmetric(20, 10))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal_wrapped(|ui| {
ui.label(
RichText::new(format!(
"{} These books wait for a format upgrade.",
icon::WARNING
))
.font(crate::theme::semibold(13.0))
.color(p.warning),
);
ui.label(
RichText::new(
"An older version of wanderbooks wrote them. They work on their old format until you agree; what needs the new one is refused.",
)
.font(crate::theme::regular(13.0))
.color(p.text),
);
let open = matches!(self.dialog, Dialog::None) && self.job.is_none();
if ui
.add_enabled(open, egui::Button::new("Upgrade…"))
.on_hover_text("Give the go-ahead, with your owner passphrase: a verified backup is made first")
.clicked()
{
self.dialog = Dialog::Upgrade(Box::new(UpgradeForm::new(upgrade)));
}
});
});
}
/// The menu button and a note of the last outcome; its dialogs.
pub fn ui(&mut self, ui: &mut Ui, books: &Arc<Books>, compact: bool) {
let Some(owner) = owner() else {
return;
};
let key = Arc::as_ptr(books) as usize;
if self.book.as_ref().is_none_or(|(k, _)| *k != key) {
self.book = Some((key, book_id(books)));
}
let Some(book) = self.book.as_ref().and_then(|(_, id)| id.clone()) else {
let Some(book) = self.book_of(books) else {
return;
};
// Upgraded elsewhere meanwhile (at a terminal): nothing to ask.
if books.format_upgrade.is_none()
&& let Dialog::Upgrade(form) = &self.dialog
&& form.working.is_none()
{
self.dialog = Dialog::None;
}
let p = crate::theme::pal(ui.ctx());
if let Some((what, rx)) = &self.job
&& let Ok(done) = rx.try_recv()
@@ -228,6 +351,16 @@ impl OwnerMenu {
));
}
}
if let Some(upgrade) = &books.format_upgrade {
ui.separator();
if ui
.button(format!("{} Upgrade the books' format…", icon::ARROW_FAT_UP))
.on_hover_text("These books wait for a format upgrade: give the go-ahead, with your owner passphrase")
.clicked()
{
self.dialog = Dialog::Upgrade(Box::new(UpgradeForm::new(upgrade.clone())));
}
}
ui.separator();
if ui
.button(format!("{} Close the books", icon::POWER))
@@ -242,7 +375,7 @@ impl OwnerMenu {
if let Some((what, work)) = start {
self.start(ui.ctx(), what, work);
}
self.dialogs(ui.ctx(), &owner);
self.dialogs(ui.ctx(), &owner, &book);
let said = if self.job.is_some() {
Some(("Working…".to_string(), p.muted))
} else {
@@ -278,12 +411,38 @@ impl OwnerMenu {
self.done = None;
}
/// The open dialog, if any.
fn dialogs(&mut self, ctx: &egui::Context, owner: &Arc<dyn Owner>) {
/// The open dialog, if any, for the books `book`.
fn dialogs(&mut self, ctx: &egui::Context, owner: &Arc<dyn Owner>, book: &str) {
let mut close = false;
let mut run: Option<(String, Box<dyn FnOnce() -> Outcome + Send>)> = None;
match &mut self.dialog {
Dialog::None => {}
Dialog::Upgrade(form) => {
let done = form.working.as_ref().and_then(|rx| match rx.try_recv() {
Ok(done) => Some(done),
Err(std::sync::mpsc::TryRecvError::Empty) => None,
// Its worker ended without a word (it died): the
// dialog, which holds the whole window, isn't left at
// work for good.
Err(std::sync::mpsc::TryRecvError::Disconnected) => Some(Err(
"it ended without an answer, so it may or may not have been done: if these books still wait, try again"
.into(),
)),
});
if let Some(done) = done {
form.working = None;
match done {
Ok(lines) => {
self.done = Some(("Format upgrade".into(), Ok(lines)));
close = true;
}
Err(why) => form.problem = Some(why),
}
}
if !close {
upgrade_dialog(ctx, form, owner, book, &mut close);
}
}
Dialog::Close => {
egui::Modal::new(egui::Id::new("owner-close")).show(ctx, |ui| {
ui.heading("Close the books?");
@@ -434,3 +593,74 @@ impl OwnerMenu {
}
}
}
/// The format upgrade's dialog (INVARIANT 5): what it is, the owner
/// passphrase, and the go-ahead or not now.
fn upgrade_dialog(
ctx: &egui::Context,
form: &mut UpgradeForm,
owner: &Arc<dyn Owner>,
book: &str,
close: &mut bool,
) {
let p = crate::theme::pal(ctx);
egui::Modal::new(egui::Id::new("owner-upgrade")).show(ctx, |ui| {
ui.set_max_width(520.0);
ui.heading("Upgrade these books' format?");
ui.add_space(4.0);
ui.label(format!(
"An older version of wanderbooks wrote these books, in journal format {}. The upgrade adds {}.",
form.upgrade.from.format,
form.upgrade.adds().join(", ")
));
ui.label("Until you agree they work as they are: everything that needs nothing new works, and what needs the new format is refused.");
ui.label("Upgrading first makes a backup of the books, their vault and keys included, and proves it whole; then it moves them to the new format, in one step. After that an older version of wanderbooks can't open them.");
ui.add_space(6.0);
let working = form.working.is_some();
ui.label("Your owner passphrase:");
let field = ui.add_enabled(
!working,
egui::TextEdit::singleline(&mut form.passphrase)
.password(true)
.desired_width(f32::INFINITY),
);
if let Some(why) = &form.problem {
ui.colored_label(p.warning, why);
}
let go = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
ui.horizontal(|ui| {
let ready = !working && !form.passphrase.is_empty();
if (ui
.add_enabled(ready, egui::Button::new("Upgrade"))
.clicked()
|| (go && ready))
&& form.working.is_none()
{
// Handed over, and gone from the form (wiped once used).
let passphrase = zeroize::Zeroizing::new(std::mem::take(&mut form.passphrase));
let (o, b) = (owner.clone(), book.to_string());
let (tx, rx) = channel();
let ctx = ui.ctx().clone();
std::thread::spawn(move || {
// The window is woken however this ends (a panic
// too), once the answer, or its absence, can be seen.
let _wake = Wake(ctx);
let (tx, passphrase) = (tx, passphrase);
let _ = tx.send(o.upgrade_format(&b, &passphrase));
});
form.working = Some(rx);
form.problem = None;
}
if ui
.add_enabled(!working, egui::Button::new("Not now"))
.on_hover_text("They keep working as they are; you'll be asked again the next time they open")
.clicked()
{
*close = true;
}
if working {
ui.label("Backing up, then upgrading…");
}
});
});
}
+396
View File
@@ -0,0 +1,396 @@
//! The viewer and the format upgrade (owner.rs INVARIANT 5, plan 3.9,
//! D-39): books waiting for one show a standing notice above every page,
//! and the go-ahead is asked as the viewer opens them, with the owner
//! passphrase, handed to the owner the program gives the viewer (the
//! server checks it). "Not now" changes nothing; a refusal is said in the
//! dialog, which stays; books this version made, and older books dirs,
//! are never asked; the viewer writes nothing of the books.
use chrono::NaiveDate;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use ledger_core::Currency;
use ledger_gui::App;
use ledger_gui::books::Books;
use ledger_gui::data::{self, Source};
use ledger_gui::nav::Route;
use ledger_gui::owner::{Agent, Owner, RestorePlan};
use ledger_store::{JournalFormat, Store};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
/// The owner passphrase the fake owner takes.
const PASS: &str = "correct horse battery";
/// The passphrase on which the fake owner's worker dies, answering nothing.
const DIES: &str = "the worker dies on this one";
/// An owner that says what it was asked, and takes only PASS.
#[derive(Default)]
struct Fake {
asked: Mutex<Vec<String>>,
}
impl Owner for Fake {
fn close_books(&self) -> Result<String, String> {
Err("not in these tests".into())
}
fn new_books(&self, _id: &str, _currency: &str) -> Result<String, String> {
Err("not in these tests".into())
}
fn back_up_now(&self, _book: &str) -> Result<Vec<String>, String> {
Err("not in these tests".into())
}
fn plan_restore(
&self,
_source: &str,
_id: Option<String>,
_replace: bool,
) -> Result<RestorePlan, String> {
Err("not in these tests".into())
}
fn restore(&self, _plan: &RestorePlan) -> Result<Vec<String>, String> {
Err("not in these tests".into())
}
fn connect(&self, _agent: Agent, _book: &str) -> Result<Vec<String>, String> {
Err("not in these tests".into())
}
fn upgrade_format(&self, book: &str, passphrase: &str) -> Result<Vec<String>, String> {
// Slow enough to be seen at work.
std::thread::sleep(std::time::Duration::from_millis(300));
// A worker that dies on the way: no answer comes back.
assert_ne!(passphrase, DIES, "the owner's worker died");
self.asked
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(format!("upgrade {book} {passphrase}"));
if passphrase == PASS {
Ok(vec![
"Backed up first, and proven whole with the vault and keys: the books as they were, through event 7 (backup 0123456789ab).".into(),
format!("Upgraded the books \"{book}\" to this version's journal format (journal format 2): now at event 8 (c4d3e2f1a0b9)."),
])
} else {
Err("that isn't the owner passphrase: nothing was changed".into())
}
}
}
/// One test at a time: they share the one owner and its record of calls.
fn alone() -> std::sync::MutexGuard<'static, ()> {
static ONE: Mutex<()> = Mutex::new(());
ONE.lock().unwrap_or_else(|e| e.into_inner())
}
/// The one owner of this test binary (the viewer takes one, once).
fn fake() -> Arc<Fake> {
static FAKE: OnceLock<Arc<Fake>> = OnceLock::new();
FAKE.get_or_init(|| {
let f = Arc::new(Fake::default());
ledger_gui::owner::set_owner(f.clone());
f
})
.clone()
}
fn asked(f: &Fake) -> Vec<String> {
f.asked.lock().unwrap().clone()
}
/// Books `acme`: in a data root (`root`) or an older books dir, in journal
/// format 1 (as an older version wrote them) or this version's (`old`).
fn books(root: bool, old: bool) -> (tempfile::TempDir, PathBuf, Books) {
let dir = tempfile::Builder::new()
.prefix("wb-gui-upgrade-")
.tempdir()
.unwrap();
let book = if root {
std::fs::write(dir.path().join(ledger_store::root::MARKER_FILE), b"").unwrap();
dir.path().join("books").join("acme")
} else {
dir.path().join("books-data")
};
std::fs::create_dir_all(&book).unwrap();
let format = if old {
JournalFormat::default()
} else {
ledger_store::format::current()
};
drop(Store::create_in_format(&book, Currency::USD, &format).unwrap());
let b = load(&book);
assert_eq!(b.format_upgrade.is_some(), old);
(dir, book, b)
}
/// The books in `book`, as the viewer loads them.
fn load(book: &Path) -> Books {
data::load_once(&Source {
data_dir: book.to_path_buf(),
today: Some(NaiveDate::from_ymd_opt(2026, 10, 20).unwrap()),
})
.unwrap()
}
/// Every file under `dir`, with its bytes.
fn files(dir: &Path) -> Vec<(PathBuf, Vec<u8>)> {
let mut out = Vec::new();
for e in std::fs::read_dir(dir).unwrap() {
let p = e.unwrap().path();
if p.is_dir() {
out.extend(files(&p));
} else {
out.push((p.clone(), std::fs::read(&p).unwrap()));
}
}
out.sort();
out
}
fn harness(b: Books) -> Harness<'static, App> {
let mut h = Harness::builder()
.with_size(egui::vec2(1200.0, 900.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, b, false, Route::Overview));
h.run_steps(4);
h
}
const ASKS: &str = "Upgrade these books' format?";
const NOTICE: &str = "These books wait for a format upgrade.";
/// Type into the dialog's passphrase field (the last text field).
fn type_passphrase(h: &mut Harness<'static, App>, text: &str) {
let field = h
.get_all_by_role(egui::accesskit::Role::PasswordInput)
.last()
.expect("a passphrase field");
field.focus();
h.run_steps(2);
h.get_all_by_role(egui::accesskit::Role::PasswordInput)
.last()
.unwrap()
.type_text(text);
h.run_steps(2);
}
/// Run until the owner was asked `n` things, and the outcome shows.
fn settle(h: &mut Harness<'static, App>, f: &Fake, n: usize) {
for _ in 0..300 {
h.run_steps(2);
if asked(f).len() >= n {
h.run_steps(6);
return;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
panic!("the owner wasn't asked: {:?}", asked(f));
}
/// D-39: asked as the viewer opens the books, and at every open until
/// given; "Not now" changes nothing and leaves the standing notice, whose
/// button asks again; nothing of the books is written.
#[test]
fn the_go_ahead_is_asked_at_every_open_with_a_standing_notice() {
let _alone = alone();
let f = fake();
let (_dir, book, b) = books(true, true);
let before = files(&book);
let n = asked(&f).len();
let mut h = harness(b);
assert!(h.query_by_label(ASKS).is_some(), "asked as they open");
assert!(
h.query_by_label_contains(NOTICE).is_some(),
"the standing notice"
);
assert!(h.query_by_label("Upgrade").is_some());
assert!(
h.query_by_label_contains("journal format 1. The upgrade adds journal format 2")
.is_some()
);
h.get_by_label("Not now").click();
h.run_steps(4);
assert!(h.query_by_label(ASKS).is_none(), "not now: gone");
assert!(
h.query_by_label_contains(NOTICE).is_some(),
"the notice stays"
);
h.run_steps(10);
assert!(h.query_by_label(ASKS).is_none(), "asked once each open");
// The notice's button asks again.
h.get_by_label("Upgrade…").click();
h.run_steps(4);
assert!(h.query_by_label(ASKS).is_some());
h.get_by_label("Not now").click();
h.run_steps(4);
// So does the Books menu.
h.get_by_label_contains(" Books").click();
h.run_steps(3);
h.get_by_label_contains("Upgrade the books' format").click();
h.run_steps(4);
assert!(h.query_by_label(ASKS).is_some());
drop(h);
// Opened again: asked again.
let h = harness(load(&book));
assert!(h.query_by_label(ASKS).is_some(), "asked at the next open");
drop(h);
assert_eq!(asked(&f).len(), n, "declining asked the owner nothing");
let after: Vec<_> = files(&book)
.into_iter()
.filter(|(p, _)| !p.starts_with(book.join("ops")))
.collect();
let before: Vec<_> = before
.into_iter()
.filter(|(p, _)| !p.starts_with(book.join("ops")))
.collect();
assert_eq!(after, before, "the viewer wrote nothing");
}
/// The go-ahead: nothing is sent without a passphrase; the one typed
/// goes to the owner as typed (which the server checks); a refusal is said
/// in the dialog, which stays, its field emptied; the right one closes it
/// and what was done is said.
#[test]
fn the_passphrase_goes_to_the_owner_and_a_refusal_is_said() {
let _alone = alone();
let f = fake();
let (_dir, _book, b) = books(true, true);
let mut h = harness(b);
let n = asked(&f).len();
h.get_by_label("Upgrade").click();
h.run_steps(4);
assert_eq!(asked(&f).len(), n, "nothing typed, nothing sent");
type_passphrase(&mut h, "not the passphrase at all");
h.get_by_label("Upgrade").click();
h.run_steps(2);
assert!(
h.query_by_label_contains("Backing up, then upgrading")
.is_some()
);
settle(&mut h, &f, n + 1);
assert_eq!(asked(&f)[n], "upgrade acme not the passphrase at all");
assert!(h.query_by_label(ASKS).is_some(), "the dialog stays");
assert!(
h.query_by_label("that isn't the owner passphrase: nothing was changed")
.is_some()
);
type_passphrase(&mut h, PASS);
h.get_by_label("Upgrade").click();
settle(&mut h, &f, n + 2);
assert_eq!(asked(&f)[n + 1], format!("upgrade acme {PASS}"));
assert!(h.query_by_label(ASKS).is_none(), "done: the dialog closes");
assert!(
h.query_by_label_contains("Format upgrade: Backed up first")
.is_some()
);
}
/// An upgrade whose worker ends without an answer (it died on the way) is
/// said in the dialog, which can then be tried again or closed: the
/// dialog, which holds the whole window, is never left at work for good.
#[test]
fn an_upgrade_that_ends_without_an_answer_never_holds_the_window() {
let _alone = alone();
let _ = fake();
let (_dir, _book, b) = books(true, true);
let mut h = harness(b);
type_passphrase(&mut h, DIES);
h.get_by_label("Upgrade").click();
h.run_steps(2);
assert!(
h.query_by_label_contains("Backing up, then upgrading")
.is_some()
);
let said = "it ended without an answer";
for _ in 0..300 {
h.run_steps(2);
if h.query_by_label_contains(said).is_some() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
// The dialog grew by what it says: settled where it now stands before
// anything in it is clicked (as `settle` does).
h.run_steps(6);
assert!(h.query_by_label_contains(said).is_some(), "said");
assert!(
h.query_by_label_contains("Backing up, then upgrading")
.is_none(),
"no longer at work"
);
assert!(h.query_by_label(ASKS).is_some(), "the dialog stays");
h.get_by_label("Not now").click();
h.run_steps(4);
assert!(h.query_by_label(ASKS).is_none(), "and closes");
}
/// Books this version made wait for nothing, and older books dirs (no
/// owner's server for them) are never asked: no notice, no dialog, no
/// menu item.
#[test]
fn books_in_this_versions_format_and_older_books_dirs_are_never_asked() {
let _alone = alone();
let _ = fake();
for (root, old) in [(true, false), (false, true), (false, false)] {
let (_dir, _book, b) = books(root, old);
let mut h = harness(b);
assert!(h.query_by_label(ASKS).is_none(), "{root} {old}");
assert!(h.query_by_label_contains(NOTICE).is_none(), "{root} {old}");
if root {
h.get_by_label_contains(" Books").click();
h.run_steps(3);
assert!(
h.query_by_label_contains("Upgrade the books' format")
.is_none()
);
}
}
}
/// Screenshots of the notice and the dialog, for eyes: set `SHOT_DIR`.
#[test]
fn screenshots() {
let Ok(out) = std::env::var("SHOT_DIR") else {
return;
};
let _alone = alone();
let out = PathBuf::from(out);
std::fs::create_dir_all(&out).unwrap();
let f = fake();
let (_dir, _book, b) = books(true, true);
let mut h = harness(b);
let shot = |h: &mut Harness<'static, App>, name: &str| {
h.run_steps(3);
h.render()
.unwrap()
.save(out.join(format!("{name}.png")))
.unwrap();
};
shot(&mut h, "upgrade-ask");
type_passphrase(&mut h, "not the passphrase at all");
let n = asked(&f).len();
h.get_by_label("Upgrade").click();
h.run_steps(2);
shot(&mut h, "upgrade-working");
settle(&mut h, &f, n + 1);
shot(&mut h, "upgrade-refused");
type_passphrase(&mut h, DIES);
h.get_by_label("Upgrade").click();
for _ in 0..300 {
h.run_steps(2);
if h.query_by_label_contains("ended without an answer")
.is_some()
{
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
shot(&mut h, "upgrade-no-answer");
h.get_by_label("Not now").click();
shot(&mut h, "upgrade-notice");
h.get_by_label_contains(" Books").click();
shot(&mut h, "upgrade-menu");
h.get_by_label_contains("Upgrade the books' format").click();
h.run_steps(3);
type_passphrase(&mut h, PASS);
let n = asked(&f).len();
h.get_by_label("Upgrade").click();
settle(&mut h, &f, n + 1);
shot(&mut h, "upgrade-done");
}
+8 -1
View File
@@ -40,7 +40,14 @@ fn books(tag: &str) -> DataDir {
let dir = std::env::temp_dir().join(format!("ledger-gui-gate-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let data = dir.join("books-data");
let mut store = Store::create(&data, Currency::USD).unwrap();
// In journal format 1, as before the format gate (this version's own
// books declare their format as they are made).
let mut store = Store::create_in_format(
&data,
Currency::USD,
&ledger_store::JournalFormat::default(),
)
.unwrap();
store
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
+4 -1
View File
@@ -85,12 +85,15 @@ const SETTINGS: &str = "[company]\nname = \"Old Books Co\"\nfiscal_year_start =
/// [`SETTINGS`] journaled into the books in `dir`, as the server adopts a
/// settings file, unless they already keep their own (P1's books): the
/// viewer reads settings only from the journal (plan 3.6).
/// viewer reads settings only from the journal (plan 3.6). As adoption
/// does, the books move to this version's journal format first (an
/// adopted copy is new books of this version).
fn write_settings(dir: &Path) {
let mut store = ledger_store::Store::open(&dir.join("books-data")).unwrap();
if store.ledger().settings().is_journaled() {
return;
}
store.upgrade_format().unwrap();
let import =
ledger_config::codec::import(SETTINGS, None, store.ledger().settings(), "USD").unwrap();
store
+4
View File
@@ -74,6 +74,10 @@ impl Owner for Fake {
self.said(format!("connect {agent:?} {book}"));
Ok(vec!["Claude Code reaches the books.".into()])
}
fn upgrade_format(&self, book: &str, _passphrase: &str) -> Result<Vec<String>, String> {
self.said(format!("upgrade {book}"));
Err("not in these tests".into())
}
}
/// One test at a time: they share the one owner and its record of calls.
+9 -8
View File
@@ -649,15 +649,16 @@ fn journal_text_for_a_creditor() {
)),
"{texts:#?}"
);
// Declared with the feature already in force (the fixture's 1099
// decisions'), sorted.
assert!(
texts
.iter()
.any(|(t, _)| t
== "Journal format 2 declared, with party_creditors, vendor_1099_decisions"),
"{texts:#?}"
// Declared when the books were created: this version's format, with
// every feature it knows (party_creditors among them), sorted.
let current = ledger_store::format::current();
assert!(current.features.iter().any(|f| f == "party_creditors"));
let declared = format!(
"Journal format {} declared, with {}",
current.format,
current.features.join(", ")
);
assert!(texts.iter().any(|(t, _)| *t == declared), "{texts:#?}");
let mut h = Harness::builder()
.with_size(egui::vec2(1440.0, 1400.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, b, false, Route::Party(bank)));