From d68b485eae92d00136d524fb8ea3d13fb74e8ab3 Mon Sep 17 00:00:00 2001 From: levallem Date: Tue, 22 Sep 2026 09:38:39 +0200 Subject: [PATCH] feat: persist PGN position snapshots by chapter --- .../down.sql | 1 + .../up.sql | 22 + src/project.rs | 524 +++++++++++++++++- 3 files changed, 537 insertions(+), 10 deletions(-) create mode 100644 project_migrations/2026-09-22-000000_create_chapter_pgn_positions/down.sql create mode 100644 project_migrations/2026-09-22-000000_create_chapter_pgn_positions/up.sql diff --git a/project_migrations/2026-09-22-000000_create_chapter_pgn_positions/down.sql b/project_migrations/2026-09-22-000000_create_chapter_pgn_positions/down.sql new file mode 100644 index 0000000..a382701 --- /dev/null +++ b/project_migrations/2026-09-22-000000_create_chapter_pgn_positions/down.sql @@ -0,0 +1 @@ +DROP TABLE chapter_pgn_positions; diff --git a/project_migrations/2026-09-22-000000_create_chapter_pgn_positions/up.sql b/project_migrations/2026-09-22-000000_create_chapter_pgn_positions/up.sql new file mode 100644 index 0000000..aa43c49 --- /dev/null +++ b/project_migrations/2026-09-22-000000_create_chapter_pgn_positions/up.sql @@ -0,0 +1,22 @@ +CREATE TABLE chapter_pgn_positions ( + id INTEGER PRIMARY KEY, + chapter_id INTEGER NOT NULL REFERENCES chapters(id) ON DELETE CASCADE, + source_game_index INTEGER NOT NULL CHECK (source_game_index >= 0), + ply_index INTEGER NOT NULL CHECK (ply_index >= 0), + selected_fen TEXT NOT NULL, + initial_fen TEXT NOT NULL, + main_line_uci TEXT NOT NULL, + header_event TEXT NULL, + header_site TEXT NULL, + header_date TEXT NULL, + header_round TEXT NULL, + header_white TEXT NULL, + header_black TEXT NULL, + header_result TEXT NULL, + header_set_up TEXT NULL, + header_fen TEXT NULL, + UNIQUE (chapter_id, initial_fen, main_line_uci, ply_index) +); + +CREATE INDEX chapter_pgn_positions_chapter_id_id + ON chapter_pgn_positions (chapter_id, id); diff --git a/src/project.rs b/src/project.rs index 96ec660..8afa521 100644 --- a/src/project.rs +++ b/src/project.rs @@ -1,14 +1,17 @@ use crate::models::Puzzle; +use crate::pgn_import::ImportedGameHeaders; +use crate::pgn_review::PgnPositionSnapshot; use diesel::Connection; +use diesel::connection::SimpleConnection; use diesel::prelude::*; -use diesel::sql_types::{Integer, Nullable, Text}; +use diesel::sql_types::{BigInt, Integer, Nullable, Text}; use diesel::sqlite::SqliteConnection; use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; use std::collections::HashSet; use std::path::Path; pub const PROJECT_APPLICATION_ID: &str = "chess-material-studio-project"; -pub const PROJECT_SCHEMA_VERSION: i32 = 3; +pub const PROJECT_SCHEMA_VERSION: i32 = 4; pub const PROJECT_MIGRATIONS: EmbeddedMigrations = embed_migrations!("project_migrations"); #[derive(Debug, Clone, PartialEq, Eq)] @@ -66,6 +69,12 @@ pub struct ProjectChapterSelectedPuzzles { pub puzzles: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PgnPositionSnapshotAddResult { + Inserted, + AlreadyExists, +} + #[derive(QueryableByName)] struct ProjectMetadataRow { #[diesel(sql_type = Text)] @@ -170,6 +179,38 @@ struct ChapterExistsRow { chapter_exists: i32, } +#[derive(QueryableByName)] +struct PgnPositionSnapshotRow { + #[diesel(sql_type = BigInt)] + source_game_index: i64, + #[diesel(sql_type = BigInt)] + ply_index: i64, + #[diesel(sql_type = Text)] + selected_fen: String, + #[diesel(sql_type = Text)] + initial_fen: String, + #[diesel(sql_type = Text)] + main_line_uci: String, + #[diesel(sql_type = Nullable)] + header_event: Option, + #[diesel(sql_type = Nullable)] + header_site: Option, + #[diesel(sql_type = Nullable)] + header_date: Option, + #[diesel(sql_type = Nullable)] + header_round: Option, + #[diesel(sql_type = Nullable)] + header_white: Option, + #[diesel(sql_type = Nullable)] + header_black: Option, + #[diesel(sql_type = Nullable)] + header_result: Option, + #[diesel(sql_type = Nullable)] + header_set_up: Option, + #[diesel(sql_type = Nullable)] + header_fen: Option, +} + impl From for ProjectChapter { fn from(row: ProjectChapterRow) -> Self { Self { @@ -223,6 +264,43 @@ impl ProjectChapterSelectedPuzzleRow { } } +impl TryFrom for PgnPositionSnapshot { + type Error = String; + + fn try_from(row: PgnPositionSnapshotRow) -> Result { + let source_game_index = usize::try_from(row.source_game_index) + .map_err(|_| "persisted PGN snapshot source game index is out of range".to_string())?; + let ply_index = usize::try_from(row.ply_index) + .map_err(|_| "persisted PGN snapshot ply index is out of range".to_string())?; + let snapshot = Self { + source_game_index, + ply_index, + selected_fen: row.selected_fen, + initial_fen: row.initial_fen, + main_line_uci: row + .main_line_uci + .split_whitespace() + .map(str::to_owned) + .collect(), + headers: ImportedGameHeaders { + event: row.header_event, + site: row.header_site, + date: row.header_date, + round: row.header_round, + white: row.header_white, + black: row.header_black, + result: row.header_result, + set_up: row.header_set_up, + fen: row.header_fen, + }, + }; + snapshot + .validate() + .map_err(|error| format!("persisted PGN snapshot is invalid: {error}"))?; + Ok(snapshot) + } +} + pub fn create_project(path: &Path, project_name: &str) -> Result { validate_name(project_name, "project name")?; @@ -236,6 +314,7 @@ pub fn create_project(path: &Path, project_name: &str) -> Result Result, String> { list_chapters_from_connection(&mut connection) } +pub fn add_pgn_position_snapshot( + path: &Path, + chapter_id: i32, + snapshot: &PgnPositionSnapshot, +) -> Result { + snapshot + .validate() + .map_err(|error| format!("cannot add PGN position snapshot: {error}"))?; + let source_game_index = i64::try_from(snapshot.source_game_index) + .map_err(|_| "PGN snapshot source game index is too large to persist".to_string())?; + let ply_index = i64::try_from(snapshot.ply_index) + .map_err(|_| "PGN snapshot ply index is too large to persist".to_string())?; + let main_line_uci = snapshot.main_line_uci.join(" "); + let mut connection = open_validated_project_connection(path)?; + + connection + .transaction::, diesel::result::Error, _>( + |connection| { + let chapter = diesel::sql_query( + "SELECT EXISTS(SELECT 1 FROM chapters WHERE id = ?) AS chapter_exists", + ) + .bind::(chapter_id) + .get_result::(connection)?; + if chapter.chapter_exists == 0 { + return Ok(Err("chapter not found".into())); + } + + let inserted = diesel::sql_query( + "INSERT INTO chapter_pgn_positions \ + (chapter_id, source_game_index, ply_index, selected_fen, initial_fen, main_line_uci, \ + header_event, header_site, header_date, header_round, header_white, header_black, \ + header_result, header_set_up, header_fen) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ + ON CONFLICT(chapter_id, initial_fen, main_line_uci, ply_index) DO NOTHING", + ) + .bind::(chapter_id) + .bind::(source_game_index) + .bind::(ply_index) + .bind::(&snapshot.selected_fen) + .bind::(&snapshot.initial_fen) + .bind::(&main_line_uci) + .bind::, _>(&snapshot.headers.event) + .bind::, _>(&snapshot.headers.site) + .bind::, _>(&snapshot.headers.date) + .bind::, _>(&snapshot.headers.round) + .bind::, _>(&snapshot.headers.white) + .bind::, _>(&snapshot.headers.black) + .bind::, _>(&snapshot.headers.result) + .bind::, _>(&snapshot.headers.set_up) + .bind::, _>(&snapshot.headers.fen) + .execute(connection)?; + if inserted == 1 { + return Ok(Ok(PgnPositionSnapshotAddResult::Inserted)); + } + + let row = diesel::sql_query( + "SELECT source_game_index, ply_index, selected_fen, initial_fen, main_line_uci, \ + header_event, header_site, header_date, header_round, header_white, header_black, \ + header_result, header_set_up, header_fen \ + FROM chapter_pgn_positions \ + WHERE chapter_id = ? AND initial_fen = ? AND main_line_uci = ? AND ply_index = ?", + ) + .bind::(chapter_id) + .bind::(&snapshot.initial_fen) + .bind::(&main_line_uci) + .bind::(ply_index) + .get_result::(connection)?; + let existing = match PgnPositionSnapshot::try_from(row) { + Ok(existing) => existing, + Err(error) => return Ok(Err(error)), + }; + if existing.headers == snapshot.headers { + Ok(Ok(PgnPositionSnapshotAddResult::AlreadyExists)) + } else { + Ok(Err( + "PGN position context already exists in this chapter with different metadata" + .into(), + )) + } + }, + ) + .map_err(|error| format!("cannot add PGN position snapshot: {error}"))? +} + +pub fn list_pgn_position_snapshots_for_chapter( + path: &Path, + chapter_id: i32, +) -> Result, String> { + let mut connection = open_read_only_project_connection(path)?; + ensure_chapter_exists(&mut connection, chapter_id)?; + diesel::sql_query( + "SELECT source_game_index, ply_index, selected_fen, initial_fen, main_line_uci, \ + header_event, header_site, header_date, header_round, header_white, header_black, \ + header_result, header_set_up, header_fen \ + FROM chapter_pgn_positions WHERE chapter_id = ? ORDER BY id ASC", + ) + .bind::(chapter_id) + .load::(&mut connection) + .map_err(|error| format!("cannot list PGN position snapshots: {error}"))? + .into_iter() + .map(PgnPositionSnapshot::try_from) + .collect() +} + pub fn rename_chapter(path: &Path, chapter_id: i32, name: &str) -> Result<(), String> { validate_name(name, "chapter name")?; @@ -553,6 +736,7 @@ fn open_validated_project_connection(path: &Path) -> Result Result Result<&str, String> { .ok_or_else(|| "project path is not valid UTF-8".to_string()) } +fn enable_project_foreign_keys(connection: &mut SqliteConnection) -> Result<(), String> { + connection + .batch_execute("PRAGMA foreign_keys = ON") + .map_err(|error| format!("cannot enable project foreign keys: {error}")) +} + #[cfg(test)] mod tests { use super::*; + use crate::pgn_import::parse_pgn; + use crate::pgn_review::{PgnPositionSnapshot, PgnReviewSession}; use diesel::Connection; use std::collections::HashSet; use std::sync::atomic::{AtomicU64, Ordering}; @@ -802,6 +995,18 @@ mod tests { existing_value: String, } + #[derive(QueryableByName)] + struct RowCount { + #[diesel(sql_type = diesel::sql_types::BigInt)] + count: i64, + } + + #[derive(QueryableByName)] + struct ForeignKeysRow { + #[diesel(sql_type = Integer)] + foreign_keys: i32, + } + #[test] fn creating_a_project_creates_a_valid_sqlite_project() { let project = TempProjectDb::new("create"); @@ -826,14 +1031,14 @@ mod tests { } #[test] - fn project_schema_version_starts_at_three() { + fn project_schema_version_starts_at_four() { let project = TempProjectDb::new("schema-version"); assert_eq!( create_project(project.path(), "Versión inicial") .unwrap() .schema_version, - 3 + 4 ); } @@ -927,6 +1132,7 @@ mod tests { "20260908000000".to_string(), "20260908010000".to_string(), "20260908020000".to_string(), + "20260922000000".to_string(), ] .into_iter() .collect() @@ -934,11 +1140,11 @@ mod tests { } #[test] - fn opening_a_valid_v1_project_upgrades_it_to_version_three() { + fn opening_a_valid_v1_project_upgrades_it_to_version_four() { let project = TempProjectDb::new("upgrade-v1"); create_valid_v1_project(&project); - assert_eq!(open_project(project.path()).unwrap().schema_version, 3); + assert_eq!(open_project(project.path()).unwrap().schema_version, 4); let mut connection = project.connection(); assert!(table_names(&mut connection).contains("chapters")); assert_eq!( @@ -947,6 +1153,7 @@ mod tests { "20260908000000".to_string(), "20260908010000".to_string(), "20260908020000".to_string(), + "20260922000000".to_string(), ] .into_iter() .collect() @@ -957,7 +1164,7 @@ mod tests { fn opening_a_future_project_version_rejects_without_modifying_it() { let project = create_test_project("future-version"); let mut connection = project.connection(); - diesel::sql_query("UPDATE project_metadata SET schema_version = 4") + diesel::sql_query("UPDATE project_metadata SET schema_version = 5") .execute(&mut connection) .unwrap(); let migration_versions_before = migration_versions(&mut connection); @@ -965,7 +1172,7 @@ mod tests { assert!(open_project(project.path()).is_err()); let metadata = read_project_metadata(&mut connection).unwrap(); - assert_eq!(metadata.schema_version, 4); + assert_eq!(metadata.schema_version, 5); assert_eq!( migration_versions(&mut connection), migration_versions_before @@ -1127,11 +1334,11 @@ mod tests { } #[test] - fn opening_a_valid_v2_project_upgrades_it_to_version_three() { + fn opening_a_valid_v2_project_upgrades_it_to_version_four() { let project = TempProjectDb::new("upgrade-v2"); create_valid_v2_project(&project); - assert_eq!(open_project(project.path()).unwrap().schema_version, 3); + assert_eq!(open_project(project.path()).unwrap().schema_version, 4); let mut connection = project.connection(); assert!(table_names(&mut connection).contains("chapter_puzzle_reviews")); assert_eq!( @@ -1140,12 +1347,98 @@ mod tests { "20260908000000".to_string(), "20260908010000".to_string(), "20260908020000".to_string(), + "20260922000000".to_string(), + ] + .into_iter() + .collect() + ); + } + + #[test] + fn opening_a_valid_v3_project_upgrades_it_to_version_four() { + let project = TempProjectDb::new("upgrade-v3"); + create_valid_v3_project(&project); + + assert_eq!(open_project(project.path()).unwrap().schema_version, 4); + let mut connection = project.connection(); + assert!(table_names(&mut connection).contains("chapter_pgn_positions")); + assert_eq!( + migration_versions(&mut connection), + [ + "20260908000000".to_string(), + "20260908010000".to_string(), + "20260908020000".to_string(), + "20260922000000".to_string(), ] .into_iter() .collect() ); } + #[test] + fn pgn_snapshot_listing_does_not_upgrade_a_v3_project() { + let project = TempProjectDb::new("pgn-snapshot-read-only-schema"); + create_valid_v3_project(&project); + + assert!(list_pgn_position_snapshots_for_chapter(project.path(), 1).is_err()); + + let mut connection = project.connection(); + assert_eq!( + read_project_metadata(&mut connection) + .unwrap() + .schema_version, + 3 + ); + assert!(!table_names(&mut connection).contains("chapter_pgn_positions")); + } + + #[test] + fn failed_v3_upgrade_rolls_back_the_pgn_position_migration() { + let project = TempProjectDb::new("upgrade-v3-pgn-rollback"); + create_valid_v3_project(&project); + let mut connection = project.connection(); + diesel::sql_query("CREATE TABLE migration_guard (existing_value TEXT NOT NULL)") + .execute(&mut connection) + .unwrap(); + diesel::sql_query("INSERT INTO migration_guard VALUES ('preserve me')") + .execute(&mut connection) + .unwrap(); + diesel::sql_query( + "CREATE INDEX chapter_pgn_positions_chapter_id_id ON migration_guard (existing_value)", + ) + .execute(&mut connection) + .unwrap(); + let migrations_before = migration_versions(&mut connection); + + assert!(open_project(project.path()).is_err()); + + assert_eq!( + read_project_metadata(&mut connection) + .unwrap() + .schema_version, + 3 + ); + assert_eq!(migration_versions(&mut connection), migrations_before); + assert!(!table_names(&mut connection).contains("chapter_pgn_positions")); + assert_eq!( + diesel::sql_query("SELECT existing_value FROM migration_guard") + .get_result::(&mut connection) + .unwrap() + .existing_value, + "preserve me" + ); + assert_eq!( + diesel::sql_query( + "SELECT name AS existing_value FROM sqlite_master \ + WHERE type = 'index' AND name = 'chapter_pgn_positions_chapter_id_id'", + ) + .get_result::(&mut connection) + .unwrap() + .existing_value, + "chapter_pgn_positions_chapter_id_id" + ); + } + #[test] fn failed_v1_upgrade_rolls_back_every_pending_project_migration() { let project = TempProjectDb::new("upgrade-v1-rollback"); @@ -1558,12 +1851,193 @@ mod tests { ); } + #[test] + fn pgn_position_snapshots_round_trip_standard_setup_and_zero_move_games() { + let project = create_test_project("pgn-snapshot-round-trip"); + let chapter = create_chapter(project.path(), "PGN", None).unwrap(); + let standard = snapshot_at( + "[Event \"Standard\"]\n[White \"Alice\"]\n[Black \"Bob\"]\n\n1. e4 e5 2. Nf3", + 2, + 4, + ); + let setup = snapshot_at( + "[Event \"Setup\"]\n[Site \"Madrid\"]\n[Date \"2026.09.22\"]\n[Round \"7\"]\n\ + [White \"Alice\"]\n[Black \"Bob\"]\n[Result \"1-0\"]\n[SetUp \"1\"]\n\ + [FEN \"8/8/8/8/8/8/8/K6k w - - 0 1\"]\n\n1. Kb1 1-0", + 1, + 8, + ); + let zero_moves = snapshot_at("[Event \"Quiet\"]\n[Result \"1/2-1/2\"]\n\n1/2-1/2", 0, 9); + + for snapshot in [&standard, &setup, &zero_moves] { + assert_eq!( + add_pgn_position_snapshot(project.path(), chapter.id, snapshot).unwrap(), + PgnPositionSnapshotAddResult::Inserted + ); + } + + let restored = list_pgn_position_snapshots_for_chapter(project.path(), chapter.id).unwrap(); + assert_eq!(restored, vec![standard, setup, zero_moves]); + assert!(restored.iter().all(|snapshot| snapshot.validate().is_ok())); + } + + #[test] + fn pgn_snapshot_duplicate_is_idempotent_when_only_source_game_index_changes() { + let project = create_test_project("pgn-snapshot-idempotent"); + let chapter = create_chapter(project.path(), "PGN", None).unwrap(); + let original = snapshot_at("[Event \"World\"]\n[White \"Carlsen\"]\n\n1. e4 e5", 1, 4); + let mut reimported = original.clone(); + reimported.source_game_index = 7; + + assert_eq!( + add_pgn_position_snapshot(project.path(), chapter.id, &original).unwrap(), + PgnPositionSnapshotAddResult::Inserted + ); + assert_eq!( + add_pgn_position_snapshot(project.path(), chapter.id, &reimported).unwrap(), + PgnPositionSnapshotAddResult::AlreadyExists + ); + assert_eq!( + list_pgn_position_snapshots_for_chapter(project.path(), chapter.id).unwrap(), + vec![original] + ); + } + + #[test] + fn pgn_snapshot_context_collision_with_different_headers_preserves_original() { + let project = create_test_project("pgn-snapshot-metadata-conflict"); + let chapter = create_chapter(project.path(), "PGN", None).unwrap(); + let original = snapshot_at( + "[Event \"World Championship\"]\n[White \"Carlsen\"]\n\n1. e4 e5", + 1, + 0, + ); + let mut conflicting = original.clone(); + conflicting.headers.event = Some("Another Event".into()); + + add_pgn_position_snapshot(project.path(), chapter.id, &original).unwrap(); + let error = + add_pgn_position_snapshot(project.path(), chapter.id, &conflicting).unwrap_err(); + + assert!(error.contains("context already exists")); + assert!(error.contains("different metadata")); + assert_eq!( + list_pgn_position_snapshots_for_chapter(project.path(), chapter.id).unwrap(), + vec![original] + ); + } + + #[test] + fn pgn_snapshot_equivalent_selected_fen_is_idempotent() { + let project = create_test_project("pgn-snapshot-equivalent-fen"); + let chapter = create_chapter(project.path(), "PGN", None).unwrap(); + let original = snapshot_at("[Event \"Equivalent\"]\n\n1. e4", 0, 0); + let mut equivalent = original.clone(); + equivalent.selected_fen.push(' '); + equivalent.validate().unwrap(); + + add_pgn_position_snapshot(project.path(), chapter.id, &original).unwrap(); + assert_eq!( + add_pgn_position_snapshot(project.path(), chapter.id, &equivalent).unwrap(), + PgnPositionSnapshotAddResult::AlreadyExists + ); + } + + #[test] + fn pgn_snapshots_are_chapter_scoped_and_distinct_contexts_coexist() { + let project = create_test_project("pgn-snapshot-chapter-scope"); + let first = create_chapter(project.path(), "Primero", None).unwrap(); + let second = create_chapter(project.path(), "Segundo", None).unwrap(); + let initial = snapshot_at("[Event \"Context\"]\n\n1. e4", 0, 0); + let later = snapshot_at("[Event \"Context\"]\n\n1. e4", 1, 0); + + assert_eq!( + add_pgn_position_snapshot(project.path(), first.id, &initial).unwrap(), + PgnPositionSnapshotAddResult::Inserted + ); + assert_eq!( + add_pgn_position_snapshot(project.path(), first.id, &later).unwrap(), + PgnPositionSnapshotAddResult::Inserted + ); + assert_eq!( + add_pgn_position_snapshot(project.path(), second.id, &initial).unwrap(), + PgnPositionSnapshotAddResult::Inserted + ); + } + + #[test] + fn pgn_snapshot_rejects_invalid_input_without_writing() { + let project = create_test_project("pgn-snapshot-invalid"); + let chapter = create_chapter(project.path(), "PGN", None).unwrap(); + let mut invalid = snapshot_at("[Event \"Invalid\"]\n\n1. e4", 1, 0); + invalid.selected_fen = "not a FEN".into(); + + assert!(add_pgn_position_snapshot(project.path(), chapter.id, &invalid).is_err()); + assert!( + list_pgn_position_snapshots_for_chapter(project.path(), chapter.id) + .unwrap() + .is_empty() + ); + } + + #[test] + fn pgn_snapshot_listing_rejects_corrupt_persisted_rows() { + let project = create_test_project("pgn-snapshot-corrupt-row"); + let chapter = create_chapter(project.path(), "PGN", None).unwrap(); + let snapshot = snapshot_at("[Event \"Corrupt\"]\n\n1. e4", 1, 0); + add_pgn_position_snapshot(project.path(), chapter.id, &snapshot).unwrap(); + + let mut connection = project.connection(); + diesel::sql_query("UPDATE chapter_pgn_positions SET main_line_uci = 'not-uci'") + .execute(&mut connection) + .unwrap(); + + assert!(list_pgn_position_snapshots_for_chapter(project.path(), chapter.id).is_err()); + } + + #[test] + fn pgn_snapshot_foreign_key_cascades_when_chapter_is_deleted() { + let project = create_test_project("pgn-snapshot-cascade"); + let chapter = create_chapter(project.path(), "PGN", None).unwrap(); + let snapshot = snapshot_at("[Event \"Cascade\"]\n\n1. e4", 1, 0); + add_pgn_position_snapshot(project.path(), chapter.id, &snapshot).unwrap(); + + let mut connection = open_validated_project_connection(project.path()).unwrap(); + assert_eq!( + diesel::sql_query("PRAGMA foreign_keys") + .get_result::(&mut connection) + .unwrap() + .foreign_keys, + 1 + ); + diesel::sql_query("DELETE FROM chapters WHERE id = ?") + .bind::(chapter.id) + .execute(&mut connection) + .unwrap(); + let count = diesel::sql_query("SELECT COUNT(*) AS count FROM chapter_pgn_positions") + .get_result::(&mut connection) + .unwrap() + .count; + assert_eq!(count, 0); + } + fn create_test_project(label: &str) -> TempProjectDb { let project = TempProjectDb::new(label); create_project(project.path(), "Proyecto de prueba").unwrap(); project } + fn snapshot_at(input: &str, ply_index: usize, source_game_index: usize) -> PgnPositionSnapshot { + let games = parse_pgn(input).unwrap(); + let mut session = PgnReviewSession::new(games).unwrap(); + for _ in 0..ply_index { + assert!(session.next_ply()); + } + let mut snapshot = session.capture_current_snapshot(); + snapshot.source_game_index = source_game_index; + snapshot + } + fn create_valid_v1_project(project: &TempProjectDb) { create_v1_project_with_application_id(project, PROJECT_APPLICATION_ID); } @@ -1588,6 +2062,36 @@ mod tests { .unwrap(); } + fn create_valid_v3_project(project: &TempProjectDb) { + create_valid_v2_project(project); + let mut connection = project.connection(); + diesel::sql_query( + "CREATE TABLE chapter_puzzle_reviews (\ + chapter_id INTEGER NOT NULL, puzzle_id TEXT NOT NULL, decision TEXT NOT NULL, \ + fen TEXT NOT NULL, moves TEXT NOT NULL, rating INTEGER NOT NULL, \ + rating_deviation INTEGER NOT NULL, popularity INTEGER NOT NULL, \ + nb_plays INTEGER NOT NULL, themes TEXT NOT NULL, game_url TEXT NOT NULL, \ + opening_tags TEXT NOT NULL, reviewed_at TEXT NOT NULL, \ + PRIMARY KEY (chapter_id, puzzle_id))", + ) + .execute(&mut connection) + .unwrap(); + diesel::sql_query( + "CREATE UNIQUE INDEX chapter_puzzle_reviews_selected_puzzle_id_unique \ + ON chapter_puzzle_reviews (puzzle_id) WHERE decision = 'selected'", + ) + .execute(&mut connection) + .unwrap(); + diesel::sql_query( + "INSERT INTO __diesel_schema_migrations (version) VALUES ('20260908020000')", + ) + .execute(&mut connection) + .unwrap(); + diesel::sql_query("UPDATE project_metadata SET schema_version = 3") + .execute(&mut connection) + .unwrap(); + } + fn create_v1_project_with_application_id(project: &TempProjectDb, application_id: &str) { let mut connection = project.connection(); diesel::sql_query(