From e60d63ece8a90d160ecd7e83c89bfb3cb9d38c6c Mon Sep 17 00:00:00 2001 From: levallem Date: Tue, 22 Sep 2026 11:27:09 +0200 Subject: [PATCH] feat: add captured PGN positions to active chapter --- src/main.rs | 205 ++++++++++++++++++++++++++++++++++++- src/pgn_tab.rs | 156 +++++++++++++++++++++++++++- src/project_tab.rs | 98 +++++++++++++++++- translations/cn/ocp.ftl | 4 + translations/en-US/ocp.ftl | 4 + translations/es/ocp.ftl | 4 + translations/fr/ocp.ftl | 4 + translations/nl/ocp.ftl | 4 + translations/pt-BR/ocp.ftl | 4 + 9 files changed, 476 insertions(+), 7 deletions(-) diff --git a/src/main.rs b/src/main.rs index 87c19e5..2ca4843 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,7 +21,7 @@ use styles::PieceTheme; use tokio::sync::mpsc::{self, Sender}; use chess::{ALL_SQUARES, Board, BoardStatus, ChessMove, Color, File, Game, Piece, Rank, Square}; -use chess_material_studio::project::ProjectPuzzleDecision; +use chess_material_studio::project::{PgnPositionSnapshotAddResult, ProjectPuzzleDecision}; use iced_aw::{TabLabel, Tabs}; use rodio::{DeviceSinkBuilder, MixerDeviceSink, Source, source::SineWave}; @@ -1185,6 +1185,43 @@ impl OfflinePuzzles { Task::none() } (_, Message::PuzzleInfo(message)) => self.puzzle_tab.update(message), + (_, Message::Pgn(PgnMessage::AddToChapter)) => { + let Some(snapshot) = self.pgn_tab.captured_snapshot().cloned() else { + return Task::none(); + }; + + let feedback = match self + .project_tab + .add_pgn_snapshot_to_active_chapter(&snapshot) + { + Ok(added) => match added.result { + PgnPositionSnapshotAddResult::Inserted => format!( + "{}: {}", + lang::tr(&self.lang, "pgn_position_added_to_chapter"), + added.chapter_name + ), + PgnPositionSnapshotAddResult::AlreadyExists => format!( + "{}: {}", + lang::tr(&self.lang, "pgn_position_already_exists_in_chapter"), + added.chapter_name + ), + }, + Err(error) => { + let no_project_open = lang::tr(&self.lang, "no_project_open"); + let no_active_chapter = lang::tr(&self.lang, "no_active_chapter"); + if error == no_project_open || error == no_active_chapter { + error + } else { + format!( + "{}: {error}", + lang::tr(&self.lang, "pgn_add_to_chapter_failed") + ) + } + } + }; + self.pgn_tab.set_add_to_chapter_feedback(feedback); + Task::none() + } (_, Message::Pgn(message)) => self.pgn_tab.update(message), (_, Message::Search(SearchMesssage::ClickSearch)) => { let generation = self.next_search_generation(); @@ -1737,7 +1774,10 @@ mod tests { use crate::openings::{Openings, Variation}; use crate::search_tab::{OpeningSide, SearchBase, TacticalThemes}; use chess_material_studio::models::Puzzle as PersistentPuzzle; - use chess_material_studio::project::{create_chapter, create_project, set_puzzle_decision}; + use chess_material_studio::project::{ + add_pgn_position_snapshot, create_chapter, create_project, + list_pgn_position_snapshots_for_chapter, set_puzzle_decision, + }; use std::collections::HashSet; use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; @@ -2169,6 +2209,167 @@ mod tests { })); } + fn capture_pgn_position(app: &mut OfflinePuzzles) { + let _ = app.update(Message::Pgn(PgnMessage::CapturePosition)); + assert!(app.pgn_tab.captured_snapshot().is_some()); + } + + #[test] + fn captured_pgn_position_is_added_to_the_active_chapter_after_navigation() { + let project = TempProjectDb::new("pgn-add-full-flow"); + create_project(&project.path, "Captured positions").unwrap(); + let chapter = create_chapter(&project.path, "Tactics", None).unwrap(); + let second_chapter = create_chapter(&project.path, "Endgames", 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 \"Capture\"]\n\n1. e4 e5 2. Nf3 1-0"); + capture_pgn_position(&mut app); + let captured_candidate = app.pgn_tab.captured_candidate().cloned(); + let captured_snapshot = app.pgn_tab.captured_snapshot().cloned().unwrap(); + + let _ = app.update(Message::Pgn(PgnMessage::NextPly)); + let _ = app.update(Message::Pgn(PgnMessage::AddToChapter)); + + assert_eq!( + list_pgn_position_snapshots_for_chapter(&project.path, chapter.id).unwrap(), + vec![captured_snapshot.clone()] + ); + assert_ne!( + app.pgn_tab.current_board().unwrap().to_string(), + captured_snapshot.selected_fen + ); + assert!( + app.pgn_tab + .status() + .unwrap() + .contains(&lang::tr(&app.lang, "pgn_position_added_to_chapter")) + ); + assert_eq!( + app.pgn_tab.captured_candidate(), + captured_candidate.as_ref() + ); + assert_eq!(app.pgn_tab.captured_snapshot(), Some(&captured_snapshot)); + + let _ = app.update(Message::Project(ProjectMessage::SelectChapter( + second_chapter.id, + ))); + let _ = app.update(Message::Pgn(PgnMessage::AddToChapter)); + assert_eq!( + list_pgn_position_snapshots_for_chapter(&project.path, second_chapter.id).unwrap(), + vec![captured_snapshot.clone()] + ); + assert_eq!( + app.pgn_tab.captured_candidate(), + captured_candidate.as_ref() + ); + assert_eq!(app.pgn_tab.captured_snapshot(), Some(&captured_snapshot)); + } + + #[test] + fn adding_the_same_captured_pgn_position_twice_reports_already_exists() { + let project = TempProjectDb::new("pgn-add-duplicate"); + create_project(&project.path, "Captured positions").unwrap(); + let chapter = create_chapter(&project.path, "Tactics", 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 \"Capture\"]\n\n1. e4 1-0"); + capture_pgn_position(&mut app); + let captured_snapshot = app.pgn_tab.captured_snapshot().cloned().unwrap(); + + let _ = app.update(Message::Pgn(PgnMessage::AddToChapter)); + let _ = app.update(Message::Pgn(PgnMessage::AddToChapter)); + + assert_eq!( + list_pgn_position_snapshots_for_chapter(&project.path, chapter.id).unwrap(), + vec![captured_snapshot.clone()] + ); + assert!(app.pgn_tab.status().unwrap().contains(&lang::tr( + &app.lang, + "pgn_position_already_exists_in_chapter" + ))); + assert_eq!(app.pgn_tab.captured_snapshot(), Some(&captured_snapshot)); + } + + #[test] + fn adding_a_captured_pgn_position_without_a_project_keeps_the_capture() { + let mut app = OfflinePuzzles::new(false); + load_pgn_for_presentation(&mut app, "1. e4 1-0"); + capture_pgn_position(&mut app); + let captured_candidate = app.pgn_tab.captured_candidate().cloned(); + let captured_snapshot = app.pgn_tab.captured_snapshot().cloned().unwrap(); + + let _ = app.update(Message::Pgn(PgnMessage::AddToChapter)); + + assert_eq!( + app.pgn_tab.status(), + Some(lang::tr(&app.lang, "no_project_open").as_str()) + ); + assert_eq!( + app.pgn_tab.captured_candidate(), + captured_candidate.as_ref() + ); + assert_eq!(app.pgn_tab.captured_snapshot(), Some(&captured_snapshot)); + } + + #[test] + fn adding_a_captured_pgn_position_without_a_chapter_keeps_the_capture() { + let project = TempProjectDb::new("pgn-add-no-chapter"); + create_project(&project.path, "Captured positions").unwrap(); + let mut app = OfflinePuzzles::new(false); + let _ = app.update(Message::Project(ProjectMessage::ProjectToOpenChosen(Some( + project.path.clone(), + )))); + load_pgn_for_presentation(&mut app, "1. e4 1-0"); + capture_pgn_position(&mut app); + let captured_snapshot = app.pgn_tab.captured_snapshot().cloned().unwrap(); + + let _ = app.update(Message::Pgn(PgnMessage::AddToChapter)); + + assert_eq!( + app.pgn_tab.status(), + Some(lang::tr(&app.lang, "no_active_chapter").as_str()) + ); + assert!(project.path.exists()); + assert_eq!(app.pgn_tab.captured_snapshot(), Some(&captured_snapshot)); + } + + #[test] + fn metadata_conflict_reports_pgn_feedback_and_preserves_the_existing_row() { + let project = TempProjectDb::new("pgn-add-metadata-conflict"); + create_project(&project.path, "Captured positions").unwrap(); + let chapter = create_chapter(&project.path, "Tactics", 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 \"Capture\"]\n\n1. e4 1-0"); + capture_pgn_position(&mut app); + let captured_candidate = app.pgn_tab.captured_candidate().cloned(); + let captured_snapshot = app.pgn_tab.captured_snapshot().cloned().unwrap(); + let mut existing_snapshot = captured_snapshot.clone(); + existing_snapshot.headers.event = Some("Existing metadata".into()); + add_pgn_position_snapshot(&project.path, chapter.id, &existing_snapshot).unwrap(); + + let _ = app.update(Message::Pgn(PgnMessage::AddToChapter)); + + assert_eq!( + list_pgn_position_snapshots_for_chapter(&project.path, chapter.id).unwrap(), + vec![existing_snapshot] + ); + let feedback = app.pgn_tab.status().unwrap(); + assert!(feedback.contains(&lang::tr(&app.lang, "pgn_add_to_chapter_failed"))); + assert!(feedback.contains("different metadata")); + assert_eq!( + app.pgn_tab.captured_candidate(), + captured_candidate.as_ref() + ); + assert_eq!(app.pgn_tab.captured_snapshot(), Some(&captured_snapshot)); + } + #[test] fn pgn_presentation_uses_the_session_board_and_ply_navigation() { let mut app = OfflinePuzzles::new(false); diff --git a/src/pgn_tab.rs b/src/pgn_tab.rs index 2f74545..b97fd40 100644 --- a/src/pgn_tab.rs +++ b/src/pgn_tab.rs @@ -6,7 +6,9 @@ use std::path::PathBuf; use chess::Board; use chess_material_studio::pgn_import::parse_pgn; -use chess_material_studio::pgn_review::{PgnPositionCandidate, PgnReviewSession}; +use chess_material_studio::pgn_review::{ + PgnPositionCandidate, PgnPositionSnapshot, PgnReviewSession, +}; use crate::styles::btn_style_simple; use crate::{Message, Tab, config, lang}; @@ -26,12 +28,14 @@ pub enum PgnMessage { NextPly, CapturePosition, ClearCapturedPosition, + AddToChapter, } pub struct PgnTab { session: Option, source: Option, captured_candidate: Option, + captured_snapshot: Option, status: Option, load_generation: u64, pub lang: lang::Language, @@ -43,6 +47,7 @@ impl PgnTab { session: None, source: None, captured_candidate: None, + captured_snapshot: None, status: None, load_generation: 0, lang: config::SETTINGS.lang, @@ -53,6 +58,24 @@ impl PgnTab { self.session.as_ref().map(PgnReviewSession::current_board) } + pub fn captured_snapshot(&self) -> Option<&PgnPositionSnapshot> { + self.captured_snapshot.as_ref() + } + + #[cfg(test)] + pub(crate) fn captured_candidate(&self) -> Option<&PgnPositionCandidate> { + self.captured_candidate.as_ref() + } + + #[cfg(test)] + pub(crate) fn status(&self) -> Option<&str> { + self.status.as_deref() + } + + pub fn set_add_to_chapter_feedback(&mut self, feedback: String) { + self.status = Some(feedback); + } + pub fn update(&mut self, message: PgnMessage) -> Task { match message { PgnMessage::ChooseFile => Task::perform(Self::choose_pgn_file(), |path| { @@ -115,13 +138,18 @@ impl PgnTab { PgnMessage::CapturePosition => { if let Some(session) = &self.session { self.captured_candidate = Some(session.capture_current_position()); + self.captured_snapshot = Some(session.capture_current_snapshot()); + self.status = None; } Task::none() } PgnMessage::ClearCapturedPosition => { self.captured_candidate = None; + self.captured_snapshot = None; + self.status = None; Task::none() } + PgnMessage::AddToChapter => Task::none(), } } @@ -144,6 +172,7 @@ impl PgnTab { self.session = Some(session); self.source = Some(source); self.captured_candidate = None; + self.captured_snapshot = None; Ok(()) } @@ -216,6 +245,8 @@ impl Tab for PgnTab { "pgn_clear_captured_position", ))) .style(btn_style_simple); + let add_to_chapter = Button::new(Text::new(lang::tr(&self.lang, "pgn_add_to_chapter"))) + .style(btn_style_simple); let previous_game = if can_previous_game { previous_game.on_press(PgnMessage::PreviousGame) @@ -242,6 +273,11 @@ impl Tab for PgnTab { } else { clear_captured_position }; + let add_to_chapter = if self.captured_snapshot.is_some() { + add_to_chapter.on_press(PgnMessage::AddToChapter) + } else { + add_to_chapter + }; content = content .push(Text::new(format!( @@ -305,7 +341,7 @@ impl Tab for PgnTab { .align_y(Alignment::Center), ) .push( - row![capture_position, clear_captured_position] + row![capture_position, clear_captured_position, add_to_chapter] .spacing(10) .align_y(Alignment::Center), ); @@ -535,6 +571,7 @@ mod tests { let mut tab = PgnTab::new(); tab.load_from_text(PathBuf::from("first.pgn"), first_game()) .expect("valid PGN must load"); + tab.set_add_to_chapter_feedback("previous add feedback".into()); let _ = tab.update(PgnMessage::CapturePosition); @@ -545,6 +582,19 @@ mod tests { assert_eq!(candidate.game_index, 0); assert_eq!(candidate.ply_index, 0); assert_eq!(candidate.board, chess::Board::default()); + + let snapshot = tab + .captured_snapshot + .as_ref() + .expect("current position snapshot must be captured"); + assert_eq!(snapshot.source_game_index, candidate.game_index); + assert_eq!(snapshot.ply_index, candidate.ply_index); + assert_eq!( + snapshot.reconstruct_selected_board().unwrap(), + candidate.board + ); + assert_eq!(snapshot.headers, candidate.headers); + assert_eq!(tab.status, None); } #[test] @@ -554,10 +604,35 @@ mod tests { .expect("valid PGN must load"); let _ = tab.update(PgnMessage::CapturePosition); let captured = tab.captured_candidate.clone(); + let snapshot = tab.captured_snapshot.clone(); + + let _ = tab.update(PgnMessage::NextPly); + + assert_eq!(tab.captured_candidate, captured); + assert_eq!(tab.captured_snapshot, snapshot); + } + #[test] + fn previous_ply_after_capture_does_not_mutate_candidate_or_snapshot() { + let mut tab = PgnTab::new(); + tab.load_from_text(PathBuf::from("first.pgn"), first_game()) + .expect("valid PGN must load"); let _ = tab.update(PgnMessage::NextPly); + let _ = tab.update(PgnMessage::CapturePosition); + let captured = tab.captured_candidate.clone(); + let snapshot = tab.captured_snapshot.clone(); + + let _ = tab.update(PgnMessage::PreviousPly); + assert_ne!( + tab.session + .as_ref() + .expect("session must remain") + .current_board(), + &captured.as_ref().expect("capture must remain").board + ); assert_eq!(tab.captured_candidate, captured); + assert_eq!(tab.captured_snapshot, snapshot); } #[test] @@ -570,10 +645,38 @@ mod tests { .expect("multiple games must load"); let _ = tab.update(PgnMessage::CapturePosition); let captured = tab.captured_candidate.clone(); + let snapshot = tab.captured_snapshot.clone(); let _ = tab.update(PgnMessage::NextGame); assert_eq!(tab.captured_candidate, captured); + assert_eq!(tab.captured_snapshot, snapshot); + } + + #[test] + fn previous_game_after_capture_does_not_mutate_candidate_or_snapshot() { + let mut tab = PgnTab::new(); + tab.load_from_text( + PathBuf::from("matches.pgn"), + "[Event \"First\"]\n1. e4 1-0\n\n[Event \"Second\"]\n1. d4 0-1", + ) + .expect("multiple games must load"); + let _ = tab.update(PgnMessage::NextGame); + let _ = tab.update(PgnMessage::NextPly); + let _ = tab.update(PgnMessage::CapturePosition); + let captured = tab.captured_candidate.clone(); + let snapshot = tab.captured_snapshot.clone(); + + let _ = tab.update(PgnMessage::PreviousGame); + + let session = tab.session.as_ref().expect("session must remain"); + assert_eq!(session.current_game_index(), 0); + assert_ne!( + session.current_board(), + &captured.as_ref().expect("capture must remain").board + ); + assert_eq!(tab.captured_candidate, captured); + assert_eq!(tab.captured_snapshot, snapshot); } #[test] @@ -583,11 +686,13 @@ mod tests { .expect("valid PGN must load"); let _ = tab.update(PgnMessage::CapturePosition); let first_candidate = tab.captured_candidate.clone(); + let first_snapshot = tab.captured_snapshot.clone(); let _ = tab.update(PgnMessage::NextPly); let _ = tab.update(PgnMessage::CapturePosition); assert_ne!(tab.captured_candidate, first_candidate); + assert_ne!(tab.captured_snapshot, first_snapshot); assert_eq!( tab.captured_candidate .as_ref() @@ -595,6 +700,13 @@ mod tests { .ply_index, 1 ); + assert_eq!( + tab.captured_snapshot + .as_ref() + .expect("replacement snapshot must exist") + .ply_index, + 1 + ); } #[test] @@ -605,10 +717,13 @@ mod tests { let _ = tab.update(PgnMessage::CapturePosition); let session = tab.session.clone(); let source = tab.source.clone(); + tab.set_add_to_chapter_feedback("previous add feedback".into()); let _ = tab.update(PgnMessage::ClearCapturedPosition); assert_eq!(tab.captured_candidate, None); + assert_eq!(tab.captured_snapshot, None); + assert_eq!(tab.status, None); assert_eq!(tab.session, session); assert_eq!(tab.source, source); } @@ -624,6 +739,7 @@ mod tests { .expect("replacement PGN must load"); assert_eq!(tab.captured_candidate, None); + assert_eq!(tab.captured_snapshot, None); } #[test] @@ -633,6 +749,7 @@ mod tests { .expect("valid PGN must load"); let _ = tab.update(PgnMessage::CapturePosition); let candidate = tab.captured_candidate.clone(); + let snapshot = tab.captured_snapshot.clone(); let _ = tab.update(PgnMessage::FileRead { generation: tab.load_generation, @@ -640,9 +757,37 @@ mod tests { content: Ok("1. not-a-move 1-0".to_string()), }); assert_eq!(tab.captured_candidate, candidate); + assert_eq!(tab.captured_snapshot, snapshot); let _ = tab.update(PgnMessage::FileSelected(None)); assert_eq!(tab.captured_candidate, candidate); + assert_eq!(tab.captured_snapshot, snapshot); + } + + #[test] + fn file_read_error_preserves_the_captured_candidate_and_snapshot() { + let mut tab = PgnTab::new(); + tab.load_from_text(PathBuf::from("first.pgn"), first_game()) + .expect("valid 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 snapshot = tab.captured_snapshot.clone(); + + let _ = tab.update(PgnMessage::FileRead { + generation: tab.load_generation, + path: PathBuf::from("unreadable.pgn"), + content: Err("read failure".to_string()), + }); + + assert_eq!(tab.session, session); + assert_eq!(tab.source, source); + assert_eq!(tab.captured_candidate, candidate); + assert_eq!(tab.captured_snapshot, snapshot); + let status = tab.status.as_deref().expect("load error must be visible"); + assert!(status.contains(&lang::tr(&tab.lang, "pgn_load_error"))); + assert!(status.contains("read failure")); } #[test] @@ -652,6 +797,7 @@ mod tests { .expect("valid PGN must load"); let _ = tab.update(PgnMessage::CapturePosition); let candidate = tab.captured_candidate.clone(); + let snapshot = tab.captured_snapshot.clone(); let stale_generation = tab.load_generation; let _ = tab.update(PgnMessage::FileSelected(Some(PathBuf::from("newer.pgn")))); @@ -662,6 +808,7 @@ mod tests { }); assert_eq!(tab.captured_candidate, candidate); + assert_eq!(tab.captured_snapshot, snapshot); } #[test] @@ -671,6 +818,7 @@ mod tests { let _ = tab.update(PgnMessage::CapturePosition); assert_eq!(tab.captured_candidate, None); + assert_eq!(tab.captured_snapshot, None); } #[test] @@ -693,6 +841,10 @@ mod tests { "pgn_capture_position", "pgn_clear_captured_position", "pgn_captured_position", + "pgn_add_to_chapter", + "pgn_position_added_to_chapter", + "pgn_position_already_exists_in_chapter", + "pgn_add_to_chapter_failed", ]; for language in lang::Language::ALL { diff --git a/src/project_tab.rs b/src/project_tab.rs index d6f72d4..4704c2e 100644 --- a/src/project_tab.rs +++ b/src/project_tab.rs @@ -5,9 +5,11 @@ use rfd::AsyncFileDialog; use std::collections::HashSet; use std::path::{Path, PathBuf}; +use chess_material_studio::pgn_review::PgnPositionSnapshot; use chess_material_studio::project::{ - ProjectChapter, ProjectMetadata, ProjectPuzzleDecision, clear_puzzle_decision, create_chapter, - create_project, find_selected_puzzle_chapter, get_puzzle_decision, list_chapters, + PgnPositionSnapshotAddResult, ProjectChapter, ProjectMetadata, ProjectPuzzleDecision, + add_pgn_position_snapshot, clear_puzzle_decision, create_chapter, create_project, + find_selected_puzzle_chapter, get_puzzle_decision, list_chapters, list_reviewed_puzzle_ids_for_chapter, list_selected_puzzles_by_chapter, list_selected_puzzles_for_chapter, open_project, set_puzzle_decision, }; @@ -120,6 +122,12 @@ pub struct PuzzleReviewView { pub status: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PgnSnapshotAddedToChapter { + pub chapter_name: String, + pub result: PgnPositionSnapshotAddResult, +} + #[derive(Debug, Clone)] pub enum ProjectMessage { ProjectNameChanged(String), @@ -178,6 +186,31 @@ impl ProjectTab { } } + pub fn add_pgn_snapshot_to_active_chapter( + &self, + snapshot: &PgnPositionSnapshot, + ) -> Result { + let Some(active_project) = self.active_project.as_ref() else { + return Err(lang::tr(&self.lang, "no_project_open")); + }; + let Some(chapter_id) = active_project.active_chapter_id else { + return Err(lang::tr(&self.lang, "no_active_chapter")); + }; + let Some(chapter) = active_project + .chapters + .iter() + .find(|chapter| chapter.id == chapter_id) + else { + return Err(lang::tr(&self.lang, "no_active_chapter")); + }; + + let result = add_pgn_position_snapshot(&active_project.path, chapter.id, snapshot)?; + Ok(PgnSnapshotAddedToChapter { + chapter_name: chapter.name.clone(), + result, + }) + } + pub fn update(&mut self, message: ProjectMessage) -> Task { match message { ProjectMessage::ProjectNameChanged(value) => { @@ -1447,7 +1480,12 @@ impl ProjectTab { #[cfg(test)] mod tests { use super::*; - use chess_material_studio::project::{ProjectPuzzleDecision, set_puzzle_decision}; + use chess_material_studio::pgn_import::parse_pgn; + use chess_material_studio::pgn_review::PgnReviewSession; + use chess_material_studio::project::{ + PgnPositionSnapshotAddResult, ProjectPuzzleDecision, + list_pgn_position_snapshots_for_chapter, set_puzzle_decision, + }; use lopdf::content::Content; use lopdf::{Document, Encoding, Object}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -1496,6 +1534,13 @@ mod tests { } } + fn sample_pgn_snapshot() -> PgnPositionSnapshot { + let games = parse_pgn("[Event \"Captured\"]\n\n1. e4 e5 1-0").unwrap(); + let mut session = PgnReviewSession::new(games).unwrap(); + assert!(session.next_ply()); + session.capture_current_snapshot() + } + #[test] fn starts_without_an_active_project() { let tab = ProjectTab::new(); @@ -1506,6 +1551,53 @@ mod tests { assert_eq!(tab.selected_export_chapter_count(), 0); } + #[test] + fn adding_a_pgn_snapshot_without_a_project_does_not_write() { + let project = TempProjectDb::new("pgn-snapshot-no-project"); + let tab = ProjectTab::new(); + + let error = tab + .add_pgn_snapshot_to_active_chapter(&sample_pgn_snapshot()) + .unwrap_err(); + + assert_eq!(error, lang::tr(&tab.lang, "no_project_open")); + assert!(!project.path.exists()); + } + + #[test] + fn adding_a_pgn_snapshot_without_an_active_chapter_does_not_write() { + let project = TempProjectDb::new("pgn-snapshot-no-chapter"); + create_project(&project.path, "No chapters").unwrap(); + let mut tab = ProjectTab::new(); + tab.open_project_path(&project.path); + + let error = tab + .add_pgn_snapshot_to_active_chapter(&sample_pgn_snapshot()) + .unwrap_err(); + + assert_eq!(error, lang::tr(&tab.lang, "no_active_chapter")); + assert!(list_chapters(&project.path).unwrap().is_empty()); + } + + #[test] + fn adding_a_pgn_snapshot_delegates_to_the_active_chapter() { + let project = TempProjectDb::new("pgn-snapshot-active-chapter"); + create_project(&project.path, "Captured positions").unwrap(); + let chapter = create_chapter(&project.path, "Tactics", None).unwrap(); + let snapshot = sample_pgn_snapshot(); + let mut tab = ProjectTab::new(); + tab.open_project_path(&project.path); + + let added = tab.add_pgn_snapshot_to_active_chapter(&snapshot).unwrap(); + + assert_eq!(added.result, PgnPositionSnapshotAddResult::Inserted); + assert_eq!(added.chapter_name, chapter.name); + assert_eq!( + list_pgn_position_snapshots_for_chapter(&project.path, chapter.id).unwrap(), + vec![snapshot] + ); + } + #[test] fn export_chapter_selection_is_validated_and_independent_from_the_active_chapter() { let project = TempProjectDb::new("export-selection"); diff --git a/translations/cn/ocp.ftl b/translations/cn/ocp.ftl index fa6f01c..6c021ad 100644 --- a/translations/cn/ocp.ftl +++ b/translations/cn/ocp.ftl @@ -518,3 +518,7 @@ pgn_next_ply = 下一半回合 pgn_capture_position = 捕获局面 pgn_clear_captured_position = 清除已捕获局面 pgn_captured_position = 已捕获局面 +pgn_add_to_chapter = 添加到章节 +pgn_position_added_to_chapter = 局面已添加到章节 +pgn_position_already_exists_in_chapter = 局面已存在于章节中 +pgn_add_to_chapter_failed = 无法添加 PGN 局面 diff --git a/translations/en-US/ocp.ftl b/translations/en-US/ocp.ftl index c38de16..327241c 100644 --- a/translations/en-US/ocp.ftl +++ b/translations/en-US/ocp.ftl @@ -517,3 +517,7 @@ pgn_next_ply = Next ply pgn_capture_position = Capture position pgn_clear_captured_position = Clear captured position pgn_captured_position = Captured position +pgn_add_to_chapter = Add to chapter +pgn_position_added_to_chapter = Position added to chapter +pgn_position_already_exists_in_chapter = Position already exists in chapter +pgn_add_to_chapter_failed = Could not add PGN position diff --git a/translations/es/ocp.ftl b/translations/es/ocp.ftl index 6723f91..5dfa158 100644 --- a/translations/es/ocp.ftl +++ b/translations/es/ocp.ftl @@ -517,3 +517,7 @@ pgn_next_ply = Ply siguiente pgn_capture_position = Capturar posición pgn_clear_captured_position = Limpiar posición capturada pgn_captured_position = Posición capturada +pgn_add_to_chapter = Añadir al capítulo +pgn_position_added_to_chapter = Posición añadida al capítulo +pgn_position_already_exists_in_chapter = La posición ya existe en el capítulo +pgn_add_to_chapter_failed = No se pudo añadir la posición PGN diff --git a/translations/fr/ocp.ftl b/translations/fr/ocp.ftl index 00b9139..2ebe534 100644 --- a/translations/fr/ocp.ftl +++ b/translations/fr/ocp.ftl @@ -517,3 +517,7 @@ pgn_next_ply = Demi-coup suivant pgn_capture_position = Capturer la position pgn_clear_captured_position = Effacer la position capturée pgn_captured_position = Position capturée +pgn_add_to_chapter = Ajouter au chapitre +pgn_position_added_to_chapter = Position ajoutée au chapitre +pgn_position_already_exists_in_chapter = La position existe déjà dans le chapitre +pgn_add_to_chapter_failed = Impossible d’ajouter la position PGN diff --git a/translations/nl/ocp.ftl b/translations/nl/ocp.ftl index 6e4865b..8cf1035 100644 --- a/translations/nl/ocp.ftl +++ b/translations/nl/ocp.ftl @@ -517,3 +517,7 @@ pgn_next_ply = Volgende halve zet pgn_capture_position = Positie vastleggen pgn_clear_captured_position = Vastgelegde positie wissen pgn_captured_position = Vastgelegde positie +pgn_add_to_chapter = Aan hoofdstuk toevoegen +pgn_position_added_to_chapter = Positie toegevoegd aan hoofdstuk +pgn_position_already_exists_in_chapter = Positie bestaat al in hoofdstuk +pgn_add_to_chapter_failed = PGN-positie kon niet worden toegevoegd diff --git a/translations/pt-BR/ocp.ftl b/translations/pt-BR/ocp.ftl index f8bbe40..a955669 100644 --- a/translations/pt-BR/ocp.ftl +++ b/translations/pt-BR/ocp.ftl @@ -517,3 +517,7 @@ pgn_next_ply = Próximo lance pgn_capture_position = Capturar posição pgn_clear_captured_position = Limpar posição capturada pgn_captured_position = Posição capturada +pgn_add_to_chapter = Adicionar ao capítulo +pgn_position_added_to_chapter = Posição adicionada ao capítulo +pgn_position_already_exists_in_chapter = A posição já existe no capítulo +pgn_add_to_chapter_failed = Não foi possível adicionar a posição PGN