From 23c5fcdf065c5ab1e283f3de70a45f0a26a05773 Mon Sep 17 00:00:00 2001 From: fy59 Date: Sun, 6 Sep 2026 08:42:10 +0200 Subject: [PATCH] Add session type persistence and TUI support --- CHANGELOG.md | 7 + docs/reviews/session_type_schema_v2.md | 28 +++ docs/roadmap.md | 14 ++ tui/include/trainlog/database.h | 2 +- tui/include/trainlog/model.h | 7 + tui/meson.build | 12 ++ tui/src/database.c | 254 +++++++++++++++++++---- tui/src/tui.c | 251 +++++++++++++++++++++- tui/tests/test_session_type_schema.c | 277 +++++++++++++++++++++++++ 9 files changed, 810 insertions(+), 42 deletions(-) create mode 100644 docs/reviews/session_type_schema_v2.md create mode 100644 tui/tests/test_session_type_schema.c diff --git a/CHANGELOG.md b/CHANGELOG.md index 794dcd2..e2c7591 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,3 +84,10 @@ Next: - added terminal performance graph; - assistance explicitly treats lower assistance as better; - best recorded set remains distinct from measured max. + +### Session type schema v2 + +- added local `training` / `max_test` session classification; +- added transactional SQLite schema migration v1 -> v2; +- preserved migrated sessions as `training`; +- kept Trainlog JSON v1 frozen and unchanged. diff --git a/docs/reviews/session_type_schema_v2.md b/docs/reviews/session_type_schema_v2.md new file mode 100644 index 0000000..2e8ce36 --- /dev/null +++ b/docs/reviews/session_type_schema_v2.md @@ -0,0 +1,28 @@ +# Session type schema v2 + +## Status + +```text +DATABASE_SCHEMA_V2=IMPLEMENTED +SESSION_TYPE_PERSISTENCE=IMPLEMENTED +TRAINLOG_FORMAT_V1=FROZEN +SESSION_TYPE_TUI=NEXT +``` + +Schema v2 adds one local SQLite field to `sessions`: + +```text +session_type = training | max_test +``` + +Rules: + +- `training` is the default; +- all rows migrated from schema v1 become `training`; +- zero-initialized `TrainlogSessionInput` values remain `training`; +- invalid database values are rejected by a SQLite `CHECK`; +- `max_test` is explicit and is never inferred from performance. + +Migration is transactional. Fresh databases are created directly as schema v2. + +The frozen Trainlog JSON v1 exchange format is unchanged. diff --git a/docs/roadmap.md b/docs/roadmap.md index 5d8e9bc..0c234aa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -124,3 +124,17 @@ TUI_EXERCISE_PERFORMANCE=IMPLEMENTED MEASURED_MAX_TRACKING=NEXT PREVIOUS_SESSION_DEFAULTS=AFTER ``` + +## Session type / measured max foundation + +```text +DATABASE_SCHEMA_V2=IMPLEMENTED +SESSION_TYPE_PERSISTENCE=IMPLEMENTED +SESSION_TYPE_TUI=NEXT +MEASURED_MAX_TRACKING=AFTER +TRAINLOG_FORMAT_V1=FROZEN +``` + +SQLite schema v2 adds `sessions.session_type` with `training` and `max_test`. +Existing v1 rows migrate to `training`. No existing session is retroactively +classified as a max test. diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index 35e8def..4057a58 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -11,7 +11,7 @@ #include "trainlog/model.h" #include "trainlog/status.h" -#define TRAINLOG_DATABASE_SCHEMA_VERSION 1 +#define TRAINLOG_DATABASE_SCHEMA_VERSION 2 typedef struct TrainlogDatabase TrainlogDatabase; diff --git a/tui/include/trainlog/model.h b/tui/include/trainlog/model.h index a3e5e84..4b1ef52 100644 --- a/tui/include/trainlog/model.h +++ b/tui/include/trainlog/model.h @@ -25,6 +25,11 @@ typedef enum TrainlogLoadMode { TRAINLOG_LOAD_ASSISTANCE } TrainlogLoadMode; +typedef enum TrainlogSessionType { + TRAINLOG_SESSION_TRAINING = 0, + TRAINLOG_SESSION_MAX_TEST +} TrainlogSessionType; + typedef struct TrainlogExercise { char exercise_id[TRAINLOG_ID_MAX + 1U]; char name[TRAINLOG_NAME_MAX + 1U]; @@ -56,6 +61,7 @@ typedef struct TrainlogSessionInput { char session_id[TRAINLOG_ID_MAX + 1U]; char started_at[TRAINLOG_TIMESTAMP_MAX + 1U]; char ended_at[TRAINLOG_TIMESTAMP_MAX + 1U]; + TrainlogSessionType session_type; const char *notes; const TrainlogSessionExerciseInput *exercises; size_t exercise_count; @@ -65,6 +71,7 @@ typedef struct TrainlogSessionSummary { char session_id[TRAINLOG_ID_MAX + 1U]; char started_at[TRAINLOG_TIMESTAMP_MAX + 1U]; char ended_at[TRAINLOG_TIMESTAMP_MAX + 1U]; + TrainlogSessionType session_type; size_t exercise_count; } TrainlogSessionSummary; diff --git a/tui/meson.build b/tui/meson.build index 9410cfe..dd492ac 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -147,3 +147,15 @@ test( 'exercise_performance', test_exercise_performance, ) + +test_session_type_schema = executable( + 'test_session_type_schema', + 'tests/test_session_type_schema.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) + +test( + 'session_type_schema', + test_session_type_schema, +) diff --git a/tui/src/database.c b/tui/src/database.c index 1f56597..5e126a3 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -17,7 +17,7 @@ struct TrainlogDatabase { sqlite3 *connection; }; -static const char *const SCHEMA_V1_SQL = +static const char *const SCHEMA_V2_SQL = "BEGIN IMMEDIATE;" "CREATE TABLE IF NOT EXISTS exercises (" @@ -34,6 +34,8 @@ static const char *const SCHEMA_V1_SQL = " session_id TEXT NOT NULL UNIQUE," " started_at TEXT NOT NULL," " ended_at TEXT," + " session_type TEXT NOT NULL DEFAULT 'training'" + " CHECK (session_type IN ('training', 'max_test'))," " notes TEXT" ");" @@ -116,7 +118,15 @@ static const char *const SCHEMA_V1_SQL = " )" ");" - "PRAGMA user_version = 1;" + "PRAGMA user_version = 2;" + "COMMIT;"; + +static const char *const MIGRATE_V1_TO_V2_SQL = + "BEGIN IMMEDIATE;" + "ALTER TABLE sessions " + "ADD COLUMN session_type TEXT NOT NULL DEFAULT 'training' " + "CHECK (session_type IN ('training', 'max_test'));" + "PRAGMA user_version = 2;" "COMMIT;"; static TrainlogStatus execute_sql( @@ -177,7 +187,10 @@ static TrainlogStatus initialize_or_validate_schema( int version = 0; TrainlogStatus status; - status = trainlog_database_schema_version(database, &version); + status = trainlog_database_schema_version( + database, + &version + ); if (status != TRAINLOG_STATUS_OK) { return status; } @@ -190,13 +203,28 @@ static TrainlogStatus initialize_or_validate_schema( return TRAINLOG_STATUS_OK; } - if (version != 0) { + if (version == 0) { + status = execute_sql( + database, + SCHEMA_V2_SQL + ); + } else if (version == 1) { + status = execute_sql( + database, + MIGRATE_V1_TO_V2_SQL + ); + } else { return TRAINLOG_STATUS_SCHEMA_UNSUPPORTED; } - status = execute_sql(database, SCHEMA_V1_SQL); if (status != TRAINLOG_STATUS_OK) { - (void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL); + (void)sqlite3_exec( + database->connection, + "ROLLBACK;", + NULL, + NULL, + NULL + ); } return status; @@ -332,6 +360,22 @@ static const char *load_mode_to_sql(TrainlogLoadMode mode) } } +static const char *session_type_to_sql( + TrainlogSessionType type +) +{ + switch (type) { + case TRAINLOG_SESSION_TRAINING: + return "training"; + + case TRAINLOG_SESSION_MAX_TEST: + return "max_test"; + + default: + return NULL; + } +} + TrainlogStatus trainlog_database_insert_exercise( TrainlogDatabase *database, const char *exercise_id, @@ -464,6 +508,29 @@ static TrainlogTrackingMode tracking_mode_from_sql(const char *text) : TRAINLOG_TRACKING_REPS; } +static bool session_type_from_sql( + const char *text, + TrainlogSessionType *output +) +{ + if (text == NULL || + output == NULL) { + return false; + } + + if (strcmp(text, "training") == 0) { + *output = TRAINLOG_SESSION_TRAINING; + return true; + } + + if (strcmp(text, "max_test") == 0) { + *output = TRAINLOG_SESSION_MAX_TEST; + return true; + } + + return false; +} + TrainlogStatus trainlog_database_list_exercises( TrainlogDatabase *database, TrainlogExercise *output, @@ -586,13 +653,27 @@ static TrainlogStatus insert_session_header( sqlite3_int64 *output_row_id ) { + static const char *const SQL = + "INSERT INTO sessions(" + "session_id, started_at, ended_at, session_type, notes" + ") VALUES(?1, ?2, ?3, ?4, ?5);"; + sqlite3_stmt *statement = NULL; + const char *session_type; int rc; + session_type = + session_type_to_sql( + session->session_type + ); + + if (session_type == NULL) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + rc = sqlite3_prepare_v2( database->connection, - "INSERT INTO sessions(session_id, started_at, ended_at, notes) " - "VALUES(?1, ?2, ?3, ?4);", + SQL, -1, &statement, NULL @@ -601,43 +682,79 @@ static TrainlogStatus insert_session_header( return TRAINLOG_STATUS_DATABASE_ERROR; } - if (sqlite3_bind_text(statement, 1, session->session_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || - sqlite3_bind_text(statement, 2, session->started_at, -1, SQLITE_TRANSIENT) != SQLITE_OK) { - (void)sqlite3_finalize(statement); - return TRAINLOG_STATUS_DATABASE_ERROR; - } + rc = sqlite3_bind_text( + statement, + 1, + session->session_id, + -1, + SQLITE_TRANSIENT + ); - if (session->ended_at[0] != '\0') { + if (rc == SQLITE_OK) { rc = sqlite3_bind_text( statement, - 3, - session->ended_at, + 2, + session->started_at, -1, SQLITE_TRANSIENT ); - } else { - rc = sqlite3_bind_null(statement, 3); - } - if (rc != SQLITE_OK) { - (void)sqlite3_finalize(statement); - return TRAINLOG_STATUS_DATABASE_ERROR; } - if (session->notes != NULL && session->notes[0] != '\0') { - rc = sqlite3_bind_text(statement, 4, session->notes, -1, SQLITE_TRANSIENT); - } else { - rc = sqlite3_bind_null(statement, 4); + if (rc == SQLITE_OK) { + rc = + session->ended_at[0] != '\0' + ? sqlite3_bind_text( + statement, + 3, + session->ended_at, + -1, + SQLITE_TRANSIENT + ) + : sqlite3_bind_null( + statement, + 3 + ); } + + if (rc == SQLITE_OK) { + rc = sqlite3_bind_text( + statement, + 4, + session_type, + -1, + SQLITE_STATIC + ); + } + + if (rc == SQLITE_OK) { + rc = + session->notes != NULL && + session->notes[0] != '\0' + ? sqlite3_bind_text( + statement, + 5, + session->notes, + -1, + SQLITE_TRANSIENT + ) + : sqlite3_bind_null( + statement, + 5 + ); + } + if (rc != SQLITE_OK) { (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR; } rc = sqlite3_step(statement); + if (rc == SQLITE_CONSTRAINT) { (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_CONFLICT; } + if (rc != SQLITE_DONE) { (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR; @@ -647,7 +764,11 @@ static TrainlogStatus insert_session_header( return TRAINLOG_STATUS_DATABASE_ERROR; } - *output_row_id = sqlite3_last_insert_rowid(database->connection); + *output_row_id = + sqlite3_last_insert_rowid( + database->connection + ); + return TRAINLOG_STATUS_OK; } @@ -906,12 +1027,18 @@ TrainlogStatus trainlog_database_list_sessions( ) { static const char *const SQL = - "SELECT s.session_id, s.started_at, COALESCE(s.ended_at, ''), " + "SELECT " + "s.session_id, " + "s.started_at, " + "COALESCE(s.ended_at, ''), " + "s.session_type, " "COUNT(se.id) " "FROM sessions AS s " - "LEFT JOIN session_exercises AS se ON se.session_row_id = s.id " + "LEFT JOIN session_exercises AS se " + "ON se.session_row_id = s.id " "GROUP BY s.id " "ORDER BY s.started_at DESC, s.id DESC;"; + sqlite3_stmt *statement = NULL; size_t count = 0U; int rc; @@ -923,19 +1050,38 @@ TrainlogStatus trainlog_database_list_sessions( return TRAINLOG_STATUS_INVALID_ARGUMENT; } - rc = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL); + rc = sqlite3_prepare_v2( + database->connection, + SQL, + -1, + &statement, + NULL + ); if (rc != SQLITE_OK) { return TRAINLOG_STATUS_DATABASE_ERROR; } while ((rc = sqlite3_step(statement)) == SQLITE_ROW) { if (count < capacity) { - const unsigned char *id = sqlite3_column_text(statement, 0); - const unsigned char *started = sqlite3_column_text(statement, 1); - const unsigned char *ended = sqlite3_column_text(statement, 2); - sqlite3_int64 exercise_count = sqlite3_column_int64(statement, 3); + const unsigned char *id = + sqlite3_column_text(statement, 0); - if (id == NULL || started == NULL || ended == NULL || + const unsigned char *started = + sqlite3_column_text(statement, 1); + + const unsigned char *ended = + sqlite3_column_text(statement, 2); + + const unsigned char *session_type = + sqlite3_column_text(statement, 3); + + sqlite3_int64 exercise_count = + sqlite3_column_int64(statement, 4); + + if (id == NULL || + started == NULL || + ended == NULL || + session_type == NULL || exercise_count < 0) { (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR; @@ -947,20 +1093,33 @@ TrainlogStatus trainlog_database_list_sessions( "%s", (const char *)id ); + (void)snprintf( output[count].started_at, sizeof(output[count].started_at), "%s", (const char *)started ); + (void)snprintf( output[count].ended_at, sizeof(output[count].ended_at), "%s", (const char *)ended ); - output[count].exercise_count = (size_t)exercise_count; + + if (!session_type_from_sql( + (const char *)session_type, + &output[count].session_type + )) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + output[count].exercise_count = + (size_t)exercise_count; } + ++count; } @@ -973,7 +1132,11 @@ TrainlogStatus trainlog_database_list_sessions( return TRAINLOG_STATUS_DATABASE_ERROR; } - *output_count = count < capacity ? count : capacity; + *output_count = + count < capacity + ? count + : capacity; + return TRAINLOG_STATUS_OK; } @@ -1440,7 +1603,7 @@ TrainlogStatus trainlog_database_get_session_details( ) { static const char *const HEADER_SQL = - "SELECT started_at, COALESCE(ended_at, '') " + "SELECT started_at, COALESCE(ended_at, ''), session_type " "FROM sessions " "WHERE session_id = ?1;"; @@ -1514,7 +1677,12 @@ TrainlogStatus trainlog_database_get_session_details( const unsigned char *ended = sqlite3_column_text(header, 1); - if (started == NULL || ended == NULL) { + const unsigned char *session_type = + sqlite3_column_text(header, 2); + + if (started == NULL || + ended == NULL || + session_type == NULL) { (void)sqlite3_finalize(header); return TRAINLOG_STATUS_DATABASE_ERROR; } @@ -1539,6 +1707,14 @@ TrainlogStatus trainlog_database_get_session_details( "%s", (const char *)ended ); + + if (!session_type_from_sql( + (const char *)session_type, + &output_session->session_type + )) { + (void)sqlite3_finalize(header); + return TRAINLOG_STATUS_DATABASE_ERROR; + } } if (sqlite3_finalize(header) != SQLITE_OK) { diff --git a/tui/src/tui.c b/tui/src/tui.c index 3a0b607..13f48a8 100644 --- a/tui/src/tui.c +++ b/tui/src/tui.c @@ -3708,6 +3708,224 @@ static bool build_session_exercise( return true; } +/* TRAINLOG_SESSION_TYPE_TUI */ + +static const char *session_type_label( + TrainlogSessionType type +) +{ + switch (type) { + case TRAINLOG_SESSION_MAX_TEST: + return "Test de max"; + + case TRAINLOG_SESSION_TRAINING: + default: + return "Entraînement"; + } +} + +static const char *session_type_history_label( + TrainlogSessionType type +) +{ + return type == TRAINLOG_SESSION_MAX_TEST + ? "[MAX]" + : "[ENTRAINEMENT]"; +} + +static bool choose_session_type( + TrainlogSessionType *output +) +{ + int selected = 0; + + if (output == NULL) { + return false; + } + + for (;;) { + WINDOW *panel; + int key; + int panel_width = + COLS - 8; + + if (panel_width < 40) { + panel_width = 40; + } + + draw_shell( + "TRAINLOG — Nouvelle séance", + "↑↓ choisir Entrée valider Échap annuler" + ); + + panel = derwin( + stdscr, + 9, + panel_width, + 3, + 4 + ); + + if (panel == NULL) { + return false; + } + + box(panel, 0, 0); + + wattron( + panel, + A_BOLD | + trainlog_theme_attribute( + TRAINLOG_COLOR_ACCENT + ) + ); + + mvwprintw( + panel, + 0, + 2, + " TYPE DE SEANCE " + ); + + wattroff( + panel, + A_BOLD | + trainlog_theme_attribute( + TRAINLOG_COLOR_ACCENT + ) + ); + + if (selected == 0) { + wattron( + panel, + A_REVERSE | + trainlog_theme_attribute( + TRAINLOG_COLOR_ACCENT + ) + ); + } + + mvwprintw( + panel, + 2, + 3, + " Entraînement " + ); + + if (selected == 0) { + wattroff( + panel, + A_REVERSE | + trainlog_theme_attribute( + TRAINLOG_COLOR_ACCENT + ) + ); + } + + wattron( + panel, + trainlog_theme_attribute( + TRAINLOG_COLOR_MUTED + ) + ); + + mvwprintw( + panel, + 3, + 5, + "Séance normale : progression, volume, travail courant." + ); + + wattroff( + panel, + trainlog_theme_attribute( + TRAINLOG_COLOR_MUTED + ) + ); + + if (selected == 1) { + wattron( + panel, + A_REVERSE | + trainlog_theme_attribute( + TRAINLOG_COLOR_ACCENT + ) + ); + } + + mvwprintw( + panel, + 5, + 3, + " Test de max " + ); + + if (selected == 1) { + wattroff( + panel, + A_REVERSE | + trainlog_theme_attribute( + TRAINLOG_COLOR_ACCENT + ) + ); + } + + wattron( + panel, + trainlog_theme_attribute( + TRAINLOG_COLOR_MUTED + ) + ); + + mvwprintw( + panel, + 6, + 5, + "Séance explicitement dédiée aux mesures de max." + ); + + wattroff( + panel, + trainlog_theme_attribute( + TRAINLOG_COLOR_MUTED + ) + ); + + syncok(panel, TRUE); + wsyncup(panel); + delwin(panel); + + refresh(); + key = getch(); + + switch (key) { + case KEY_UP: + case KEY_LEFT: + case KEY_DOWN: + case KEY_RIGHT: + selected = + selected == 0 + ? 1 + : 0; + break; + + case '\n': + case KEY_ENTER: + *output = + selected == 0 + ? TRAINLOG_SESSION_TRAINING + : TRAINLOG_SESSION_MAX_TEST; + return true; + + case 27: + return false; + + default: + break; + } + } +} + static void screen_new_session(TrainlogDatabase *database) { TrainlogSessionExerciseInput exercise_inputs[MAX_SESSION_EXERCISES]; @@ -3716,6 +3934,8 @@ static void screen_new_session(TrainlogDatabase *database) char session_id[TRAINLOG_GENERATED_ID_CAPACITY]; char started_at[TRAINLOG_TIMESTAMP_MAX + 1U]; char ended_at[TRAINLOG_TIMESTAMP_MAX + 1U]; + TrainlogSessionType session_type = + TRAINLOG_SESSION_TRAINING; size_t exercise_count = 0U; int another = 1; TrainlogStatus status; @@ -3740,6 +3960,12 @@ static void screen_new_session(TrainlogDatabase *database) exercise_count = 0U; + if (!choose_session_type( + &session_type + )) { + return; + } + if (trainlog_id_generate( "se", session_id, @@ -3788,6 +4014,9 @@ static void screen_new_session(TrainlogDatabase *database) } (void)memset(&session, 0, sizeof(session)); + session.session_type = + session_type; + (void)snprintf( session.session_id, sizeof(session.session_id), @@ -3819,7 +4048,13 @@ static void screen_new_session(TrainlogDatabase *database) attroff(trainlog_theme_attribute(TRAINLOG_COLOR_SUCCESS)); mvprintw(6, 2, "Début : %s", started_at); mvprintw(7, 2, "Fin : %s", ended_at); - mvprintw(8, 2, "Exercices : %zu", exercise_count); + mvprintw( + 8, + 2, + "Type : %s", + session_type_label(session_type) + ); + mvprintw(9, 2, "Exercices : %zu", exercise_count); } else { status_line( "Échec lors de l'enregistrement de la séance.", @@ -3894,6 +4129,15 @@ static void screen_session_detail( : "séance ouverte" ); + mvprintw( + 5, + 4, + "Type : %s", + session_type_label( + session.session_type + ) + ); + if (count == 0U) { mvprintw(7, 4, "Aucun exercice dans cette séance."); } else { @@ -4120,8 +4364,11 @@ static void screen_history(TrainlogDatabase *database) mvprintw( item_row, 4, - " %-25s %2zu exercice(s) ", + " %-25s %-14s %2zu exercice(s) ", sessions[absolute].started_at, + session_type_history_label( + sessions[absolute].session_type + ), sessions[absolute].exercise_count ); diff --git a/tui/tests/test_session_type_schema.c b/tui/tests/test_session_type_schema.c new file mode 100644 index 0000000..9755fa9 --- /dev/null +++ b/tui/tests/test_session_type_schema.c @@ -0,0 +1,277 @@ +/** + * @file test_session_type_schema.c + * @brief Schema v2 and session type persistence tests. + */ + +#include +#include +#include +#include +#include + +#include + +#include "trainlog/database.h" + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + (void)fprintf( \ + stderr, \ + "CHECK failed at %s:%d: %s\n", \ + __FILE__, \ + __LINE__, \ + #condition \ + ); \ + return false; \ + } \ + } while (0) + +static bool test_session_type_roundtrip(void) +{ + TrainlogDatabase *database = NULL; + TrainlogSessionInput training; + TrainlogSessionInput max_test; + TrainlogSessionSummary sessions[4]; + size_t count = 0U; + + CHECK( + trainlog_database_open( + ":memory:", + &database + ) == TRAINLOG_STATUS_OK + ); + + (void)memset(&training, 0, sizeof(training)); + + (void)snprintf( + training.session_id, + sizeof(training.session_id), + "%s", + "se_training" + ); + + (void)snprintf( + training.started_at, + sizeof(training.started_at), + "%s", + "2026-09-05T18:00:00+02:00" + ); + + CHECK( + training.session_type == + TRAINLOG_SESSION_TRAINING + ); + + CHECK( + trainlog_database_insert_session( + database, + &training + ) == TRAINLOG_STATUS_OK + ); + + (void)memset(&max_test, 0, sizeof(max_test)); + + (void)snprintf( + max_test.session_id, + sizeof(max_test.session_id), + "%s", + "se_max_test" + ); + + (void)snprintf( + max_test.started_at, + sizeof(max_test.started_at), + "%s", + "2026-09-05T19:00:00+02:00" + ); + + max_test.session_type = + TRAINLOG_SESSION_MAX_TEST; + + CHECK( + trainlog_database_insert_session( + database, + &max_test + ) == TRAINLOG_STATUS_OK + ); + + CHECK( + trainlog_database_list_sessions( + database, + sessions, + 4U, + &count + ) == TRAINLOG_STATUS_OK + ); + + CHECK(count == 2U); + + CHECK( + strcmp( + sessions[0].session_id, + "se_max_test" + ) == 0 + ); + + CHECK( + sessions[0].session_type == + TRAINLOG_SESSION_MAX_TEST + ); + + CHECK( + strcmp( + sessions[1].session_id, + "se_training" + ) == 0 + ); + + CHECK( + sessions[1].session_type == + TRAINLOG_SESSION_TRAINING + ); + + trainlog_database_close(database); + return true; +} + +static bool test_v1_to_v2_migration(void) +{ + char path[] = + "/tmp/trainlog-schema-v1-XXXXXX"; + + static const char *const V1_SQL = + "CREATE TABLE sessions (" + "id INTEGER PRIMARY KEY," + "session_id TEXT NOT NULL UNIQUE," + "started_at TEXT NOT NULL," + "ended_at TEXT," + "notes TEXT" + ");" + "INSERT INTO sessions(" + "session_id, started_at, ended_at, notes" + ") VALUES(" + "'se_old'," + "'2026-08-01T10:00:00+02:00'," + "NULL," + "NULL" + ");" + "PRAGMA user_version = 1;"; + + sqlite3 *raw = NULL; + sqlite3_stmt *statement = NULL; + TrainlogDatabase *database = NULL; + int fd; + int version = 0; + int rc; + + fd = mkstemp(path); + CHECK(fd >= 0); + CHECK(close(fd) == 0); + + rc = sqlite3_open(path, &raw); + CHECK(rc == SQLITE_OK); + + CHECK( + sqlite3_exec( + raw, + V1_SQL, + NULL, + NULL, + NULL + ) == SQLITE_OK + ); + + CHECK(sqlite3_close(raw) == SQLITE_OK); + raw = NULL; + + CHECK( + trainlog_database_open( + path, + &database + ) == TRAINLOG_STATUS_OK + ); + + CHECK( + trainlog_database_schema_version( + database, + &version + ) == TRAINLOG_STATUS_OK + ); + + CHECK( + version == + TRAINLOG_DATABASE_SCHEMA_VERSION + ); + + trainlog_database_close(database); + database = NULL; + + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + + CHECK( + sqlite3_prepare_v2( + raw, + "SELECT session_type " + "FROM sessions " + "WHERE session_id = 'se_old';", + -1, + &statement, + NULL + ) == SQLITE_OK + ); + + CHECK(sqlite3_step(statement) == SQLITE_ROW); + + { + const unsigned char *type = + sqlite3_column_text(statement, 0); + + CHECK(type != NULL); + + CHECK( + strcmp( + (const char *)type, + "training" + ) == 0 + ); + } + + CHECK( + sqlite3_finalize(statement) == + SQLITE_OK + ); + + statement = NULL; + + rc = sqlite3_exec( + raw, + "INSERT INTO sessions(" + "session_id, started_at, session_type" + ") VALUES(" + "'se_invalid'," + "'2026-08-02T10:00:00+02:00'," + "'invalid'" + ");", + NULL, + NULL, + NULL + ); + + CHECK(rc == SQLITE_CONSTRAINT); + + CHECK(sqlite3_close(raw) == SQLITE_OK); + raw = NULL; + + CHECK(unlink(path) == 0); + return true; +} + +int main(void) +{ + CHECK(test_session_type_roundtrip()); + CHECK(test_v1_to_v2_migration()); + + (void)printf("PASS session_type_schema\n"); + return 0; +}