From 00cadd5b989b5fb46e5153a1d53d2cd3ac0d50d4 Mon Sep 17 00:00:00 2001 From: levallem Date: Tue, 22 Sep 2026 19:33:27 +0200 Subject: [PATCH] feat: open saved PGN position in review --- src/main.rs | 75 +++++++++++++++++++++++++++++++ src/pgn_tab.rs | 108 +++++++++++++++++++++++++++++++++++++++++++++ src/project_tab.rs | 41 ++++++++++++++++- 3 files changed, 223 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 2ca4843..066ffce 100644 --- a/src/main.rs +++ b/src/main.rs @@ -958,6 +958,15 @@ impl OfflinePuzzles { Task::none() } (_, Message::Settings(message)) => self.settings_tab.update(message), + (_, Message::Project(ProjectMessage::OpenPgnPosition(index))) => { + let Some(snapshot) = self.project_tab.pgn_position_snapshot_at(index) else { + return Task::none(); + }; + if self.pgn_tab.load_snapshot(&snapshot).is_ok() { + self.active_tab = TabId::Pgn; + } + Task::none() + } (_, Message::Project(message)) => { let task = self.project_tab.update(message); self.refresh_current_puzzle_review(); @@ -2267,6 +2276,72 @@ mod tests { assert_eq!(app.pgn_tab.captured_snapshot(), Some(&captured_snapshot)); } + #[test] + fn opening_a_saved_pgn_position_uses_the_cached_snapshot_without_touching_other_state() { + let project = TempProjectDb::new("pgn-open-saved-position"); + create_project(&project.path, "Saved positions").unwrap(); + create_chapter(&project.path, "PGN", None).unwrap(); + let mut app = OfflinePuzzles::new(false); + let _ = app.update(Message::Project(ProjectMessage::ProjectToOpenChosen(Some( + project.path.clone(), + )))); + load_pgn_for_presentation(&mut app, "[Event \"Saved\"]\n\n1. e4 e5 2. Nf3 1-0"); + let _ = app.update(Message::Pgn(PgnMessage::NextPly)); + let _ = app.update(Message::Pgn(PgnMessage::NextPly)); + capture_pgn_position(&mut app); + let snapshot = app.pgn_tab.captured_snapshot().cloned().unwrap(); + let _ = app.update(Message::Pgn(PgnMessage::AddToChapter)); + app.game_mode = config::GameMode::Analysis; + let analysis_board = Board::from_str("8/8/8/8/8/8/8/K6k w - - 0 1").unwrap(); + app.analysis = Game::new_with_board(analysis_board); + app.analysis_history = vec![analysis_board]; + let puzzle_board = app.board; + let puzzle_index = app.puzzle_tab.current_puzzle; + let active_tab_before_invalid_open = app.active_tab; + let pgn_before_invalid_open = app.pgn_tab.review_session().cloned(); + std::fs::remove_file(&project.path).unwrap(); + + let _ = app.update(Message::Project(ProjectMessage::OpenPgnPosition(1))); + assert_eq!(app.active_tab, active_tab_before_invalid_open); + assert_eq!( + app.pgn_tab.review_session(), + pgn_before_invalid_open.as_ref() + ); + + let _ = app.update(Message::Project(ProjectMessage::OpenPgnPosition(0))); + + let session = app + .pgn_tab + .review_session() + .expect("saved session must load"); + assert_eq!(app.active_tab, TabId::Pgn); + assert_eq!(session.game_count(), 1); + assert_eq!(session.current_ply_index(), snapshot.ply_index); + assert_eq!(session.current_game().headers, snapshot.headers); + assert_eq!( + app.board_for_presentation().to_string(), + snapshot.selected_fen + ); + assert_eq!(app.game_mode, config::GameMode::Analysis); + assert_eq!(app.analysis.current_position(), analysis_board); + assert_eq!(app.analysis_history, vec![analysis_board]); + assert_eq!(app.board, puzzle_board); + assert_eq!(app.puzzle_tab.current_puzzle, puzzle_index); + assert_eq!(app.project_tab.pgn_position_snapshot_at(0), Some(snapshot)); + } + + #[test] + fn opening_an_invalid_saved_pgn_index_fails_closed() { + let mut app = OfflinePuzzles::new(false); + let active_tab = app.active_tab; + let session = app.pgn_tab.review_session().cloned(); + + let _ = app.update(Message::Project(ProjectMessage::OpenPgnPosition(0))); + + assert_eq!(app.active_tab, active_tab); + assert_eq!(app.pgn_tab.review_session(), session.as_ref()); + } + #[test] fn adding_the_same_captured_pgn_position_twice_reports_already_exists() { let project = TempProjectDb::new("pgn-add-duplicate"); diff --git a/src/pgn_tab.rs b/src/pgn_tab.rs index b97fd40..a6ed406 100644 --- a/src/pgn_tab.rs +++ b/src/pgn_tab.rs @@ -62,11 +62,37 @@ impl PgnTab { self.captured_snapshot.as_ref() } + pub fn load_snapshot(&mut self, snapshot: &PgnPositionSnapshot) -> Result<(), String> { + let session = match PgnReviewSession::from_snapshot(snapshot) { + Ok(session) => session, + Err(error) => { + self.status = Some(format!( + "{}: {error}", + lang::tr(&self.lang, "pgn_load_error") + )); + return Err(error); + } + }; + + self.session = Some(session); + self.source = None; + self.captured_candidate = None; + self.captured_snapshot = None; + self.status = None; + self.next_load_generation(); + Ok(()) + } + #[cfg(test)] pub(crate) fn captured_candidate(&self) -> Option<&PgnPositionCandidate> { self.captured_candidate.as_ref() } + #[cfg(test)] + pub(crate) fn review_session(&self) -> Option<&PgnReviewSession> { + self.session.as_ref() + } + #[cfg(test)] pub(crate) fn status(&self) -> Option<&str> { self.status.as_deref() @@ -811,6 +837,88 @@ mod tests { assert_eq!(tab.captured_snapshot, snapshot); } + fn saved_snapshot() -> PgnPositionSnapshot { + let games = parse_pgn(first_game()).expect("fixture PGN must parse"); + let mut session = PgnReviewSession::new(games).expect("fixture session must load"); + assert!(session.next_ply()); + assert!(session.next_ply()); + session.capture_current_snapshot() + } + + #[test] + fn loading_a_saved_snapshot_rebuilds_the_session_and_clears_transient_state() { + let snapshot = saved_snapshot(); + let mut tab = PgnTab::new(); + tab.load_from_text(PathBuf::from("old.pgn"), first_game()) + .expect("old PGN must load"); + let _ = tab.update(PgnMessage::CapturePosition); + tab.set_add_to_chapter_feedback("old feedback".into()); + + tab.load_snapshot(&snapshot) + .expect("saved snapshot must reconstruct"); + + let session = tab.session.as_mut().expect("saved session must exist"); + assert_eq!(session.game_count(), 1); + assert_eq!(session.current_ply_index(), snapshot.ply_index); + assert_eq!(session.current_game().headers, snapshot.headers); + assert_eq!(session.current_board().to_string(), snapshot.selected_fen); + assert!(session.previous_ply()); + assert!(session.next_ply()); + assert_eq!(tab.source, None); + assert_eq!(tab.captured_candidate, None); + assert_eq!(tab.captured_snapshot, None); + assert_eq!(tab.status, None); + } + + #[test] + fn invalid_saved_snapshot_preserves_existing_state_and_reports_feedback() { + let mut invalid_snapshot = saved_snapshot(); + invalid_snapshot.initial_fen = "not a FEN".into(); + let mut tab = PgnTab::new(); + tab.load_from_text(PathBuf::from("old.pgn"), first_game()) + .expect("old PGN must load"); + let _ = tab.update(PgnMessage::CapturePosition); + let session = tab.session.clone(); + let source = tab.source.clone(); + let candidate = tab.captured_candidate.clone(); + let captured_snapshot = tab.captured_snapshot.clone(); + let generation = tab.load_generation; + + assert!(tab.load_snapshot(&invalid_snapshot).is_err()); + + assert_eq!(tab.session, session); + assert_eq!(tab.source, source); + assert_eq!(tab.captured_candidate, candidate); + assert_eq!(tab.captured_snapshot, captured_snapshot); + assert_eq!(tab.load_generation, generation); + assert!( + tab.status + .as_deref() + .is_some_and(|status| status.contains(&lang::tr(&tab.lang, "pgn_load_error"))) + ); + } + + #[test] + fn stale_file_read_cannot_replace_a_loaded_saved_snapshot() { + let snapshot = saved_snapshot(); + let mut tab = PgnTab::new(); + let _ = tab.update(PgnMessage::FileSelected(Some(PathBuf::from("pending.pgn")))); + let pending_generation = tab.load_generation; + + tab.load_snapshot(&snapshot) + .expect("saved snapshot must reconstruct"); + let saved_session = tab.session.clone(); + + let _ = tab.update(PgnMessage::FileRead { + generation: pending_generation, + path: PathBuf::from("pending.pgn"), + content: Ok("1. d4 0-1".into()), + }); + + assert_eq!(tab.session, saved_session); + assert_eq!(tab.source, None); + } + #[test] fn capture_without_a_session_is_inert() { let mut tab = PgnTab::new(); diff --git a/src/project_tab.rs b/src/project_tab.rs index 50a65dc..13989ae 100644 --- a/src/project_tab.rs +++ b/src/project_tab.rs @@ -174,6 +174,7 @@ pub enum ProjectMessage { SelectedPuzzlesPgnExportFinished(SelectedPuzzlesPgnExportResult), ExportSelectedPuzzlesPdf, SelectedPuzzlesPdfExportFinished(SelectedPuzzlesPdfExportResult), + OpenPgnPosition(usize), } pub struct ProjectTab { @@ -236,6 +237,10 @@ impl ProjectTab { }) } + pub fn pgn_position_snapshot_at(&self, index: usize) -> Option { + self.pgn_positions()?.get(index).cloned() + } + pub fn update(&mut self, message: ProjectMessage) -> Task { match message { ProjectMessage::ProjectNameChanged(value) => { @@ -295,6 +300,7 @@ impl ProjectTab { self.select_chapter(chapter_id); Task::none() } + ProjectMessage::OpenPgnPosition(_) => Task::none(), ProjectMessage::SetChapterExportSelected { chapter_id, selected, @@ -1600,7 +1606,12 @@ impl ProjectTab { "{} {}", lang::tr(&self.lang, "fen"), snapshot.selected_fen, - ))); + ))) + .push( + Button::new(Text::new(lang::tr(&self.lang, "open"))) + .on_press(ProjectMessage::OpenPgnPosition(index)) + .style(btn_style_simple), + ); } return Some(content); } @@ -3493,6 +3504,7 @@ mod tests { tab.refresh_pgn_positions(); assert!(tab.pgn_positions().is_none()); + assert_eq!(tab.pgn_position_snapshot_at(0), None); assert!(tab.pgn_positions_error().is_some()); let _ = tab.content(); } @@ -3515,6 +3527,33 @@ mod tests { assert_eq!(tab.pgn_positions().unwrap(), before); } + #[test] + fn cached_pgn_snapshot_accessor_uses_only_the_active_context() { + let project = TempProjectDb::new("pgn-positions-open-accessor"); + create_project(&project.path, "Open").unwrap(); + let first_chapter = create_chapter(&project.path, "First", None).unwrap(); + let second_chapter = create_chapter(&project.path, "Second", None).unwrap(); + let first = sample_pgn_snapshot(); + let mut second = sample_pgn_snapshot(); + second.headers.event = Some("Second".into()); + add_pgn_position_snapshot(&project.path, first_chapter.id, &first).unwrap(); + add_pgn_position_snapshot(&project.path, second_chapter.id, &second).unwrap(); + let mut tab = ProjectTab::new(); + + assert_eq!(tab.pgn_position_snapshot_at(0), None); + tab.open_project_path(&project.path); + assert_eq!(tab.pgn_position_snapshot_at(0), Some(first)); + assert_eq!(tab.pgn_position_snapshot_at(1), None); + + tab.select_chapter(second_chapter.id); + assert_eq!(tab.pgn_position_snapshot_at(0), Some(second)); + std::fs::remove_file(&project.path).unwrap(); + let _ = tab.content(); + assert!(tab.pgn_position_snapshot_at(0).is_some()); + let _ = tab.update(ProjectMessage::CloseProject); + assert_eq!(tab.pgn_position_snapshot_at(0), None); + } + #[test] fn pgn_position_translation_keys_exist_for_every_supported_language() { const PGN_POSITION_KEYS: [&str; 3] = [