#define _POSIX_C_SOURCE 200809L #include #include #include #include #include #include #include "trainlog/database.h" #include "trainlog/web_heart_rate.h" #include "database_internal.h" #define CHECK(value) \ do { \ if (!(value)) { \ fprintf(stderr, "CHECK failed line %d: %s\n", __LINE__, #value); \ goto cleanup; \ } \ } while (0) static const char *const SESSION_ID = "se_11111111-1111-4111-8111-111111111111"; static const char *const ENTRY_ID = "sxe_22222222-2222-4222-8222-222222222222"; static const char *const RUN_ID = "cgr_55555555-5555-4555-8555-555555555555"; static const char *const CALIBRATION_ID = "cal_77777777-7777-4777-8777-777777777777"; static const char *const SLEEP_ID = "sl_88888888-8888-4888-8888-888888888888"; static bool execute_sql(sqlite3 *database, const char *sql) { int rc = sqlite3_exec(database, sql, NULL, NULL, NULL); if (rc != SQLITE_OK) { fprintf(stderr, "seed SQLite rc=%d: %s\n", rc, sqlite3_errmsg(database)); return false; } return true; } static bool seed(sqlite3 *database) { const char *const statements[] = { "INSERT INTO " "exercises(exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields)" " VALUES('ex_33333333-3333-4333-8333-333333333333','Vélo guidé','velo guide'," "'continuous','duration',0);" "INSERT INTO sessions(session_id,started_at,ended_at,session_type,session_kind)" " VALUES('se_11111111-1111-4111-8111-111111111111','2026-09-23T10:00:00+02:00'," "'2026-09-23T10:10:00+02:00','training','cardio');", "INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,recording_mode," "tracking_mode,data_fields,position,load_mode,rest_seconds)" " SELECT 'sxe_22222222-2222-4222-8222-222222222222',s.id,e.id,'continuous','duration'," "0,0,'none',0 FROM sessions s,exercises e WHERE " "s.session_id='se_11111111-1111-4111-8111-111111111111' AND " "e.exercise_id='ex_33333333-3333-4333-8333-333333333333';" "INSERT INTO " "session_exercise_timeline(session_id,entry_id,exercise_id,started_at,ended_at,imported_at)" " VALUES('se_11111111-1111-4111-8111-111111111111'," "'sxe_22222222-2222-4222-8222-222222222222'," "'ex_33333333-3333-4333-8333-333333333333','2026-09-23T10:00:10+02:00'," "'2026-09-23T10:09:50+02:00','2026-09-23T10:11:00+02:00');", "INSERT INTO " "heart_rate_captures(capture_id,context_kind,context_id,started_at,ended_at,sensor_name," "imported_at)" " VALUES('hrc_44444444-4444-4444-8444-444444444444','cardio'," "'se_11111111-1111-4111-8111-111111111111','2026-09-23T10:00:00+02:00'," "'2026-09-23T10:10:00+02:00','CYCPLUS H2','2026-09-23T10:11:00+02:00');" "INSERT INTO heart_rate_samples(capture_id,sequence,observed_at,bpm,exercise_entry_id," "sensor_contact_detected,energy_expended) VALUES" "('hrc_44444444-4444-4444-8444-444444444444',0,'2026-09-23T10:00:20+02:00',110," "'sxe_22222222-2222-4222-8222-222222222222',NULL,NULL)," "('hrc_44444444-4444-4444-8444-444444444444',1,'2026-09-23T10:00:40+02:00',130," "'sxe_22222222-2222-4222-8222-222222222222',NULL,NULL);" "INSERT INTO heart_rate_rr_intervals(capture_id,sample_sequence,rr_index,value_1024)" " VALUES('hrc_44444444-4444-4444-8444-444444444444',0,0,1024);", "INSERT INTO cardio_calibrations(calibration_id,protocol_version,session_id,entry_id," "started_at,effort_end_at,ended_at,heart_rate_capture_id,observed_peak_bpm,imported_at)" " VALUES('cal_77777777-7777-4777-8777-777777777777',1," "'se_11111111-1111-4111-8111-111111111111','sxe_22222222-2222-4222-8222-222222222222'," "'2026-09-23T10:00:00+02:00','2026-09-23T10:05:00+02:00','2026-09-23T10:10:00+02:00'," "'hrc_44444444-4444-4444-8444-444444444444',160,'2026-09-23T10:11:00+02:00');" "INSERT INTO " "cardio_calibration_recovery(calibration_id,target_offset_seconds,observed_at,bpm)" " VALUES('cal_77777777-7777-4777-8777-777777777777',60,'2026-09-23T10:06:00+02:00',140);", "INSERT INTO cardio_guidance_runs(run_id,session_id,started_at,ended_at,imported_at)" " VALUES('cgr_55555555-5555-4555-8555-555555555555'," "'se_11111111-1111-4111-8111-111111111111','2026-09-23T10:00:20+02:00'," "'2026-09-23T10:03:20+02:00','2026-09-23T10:11:00+02:00');" "INSERT INTO cardio_guidance_phases(run_id,phase_id,entry_id,position,kind,target_min_bpm," "target_max_bpm,calibration_id,calibration_observed_peak_bpm,minimum_percent,maximum_" "percent," "exit_kind,exit_seconds,exit_bpm,started_at,ended_at,final_instruction)" " VALUES('cgr_55555555-5555-4555-8555-555555555555'," "'cgp_66666666-6666-4666-8666-666666666666','sxe_22222222-2222-4222-8222-222222222222'," "0,'work',120,140,'cal_77777777-7777-4777-8777-777777777777',160,75,88," "'fixed_duration',180,NULL,'2026-09-23T10:00:20+02:00','2026-09-23T10:03:20+02:00','" "maintain');", "INSERT INTO cardio_guidance_events(run_id,phase_id,sequence,observed_at,instruction,bpm," "target_min_bpm,target_max_bpm) VALUES" "('cgr_55555555-5555-4555-8555-555555555555','cgp_66666666-6666-4666-8666-666666666666'," "0,'2026-09-23T10:00:20+02:00','accelerate',110,120,140)," "('cgr_55555555-5555-4555-8555-555555555555','cgp_66666666-6666-4666-8666-666666666666'," "1,'2026-09-23T10:00:40+02:00','maintain',130,120,140);", "INSERT INTO " "sleep_diary_entries(entry_id,night_start_date,night_end_date,created_at,updated_at," "current_revision_id,deleted) " "VALUES('sl_88888888-8888-4888-8888-888888888888','2026-09-22'," "'2026-09-23','2026-09-22T22:00:00+02:00','2026-09-23T06:30:00+02:00'," "'slr_99999999-9999-4999-8999-999999999999',0);" "INSERT INTO sleep_diary_revisions(revision_id,entry_id,parent_revision_id,created_at," "sleep_quality,wake_quality,day_form,treatment_and_notes) VALUES(" "'slr_99999999-9999-4999-8999-999999999999','sl_88888888-8888-4888-8888-888888888888'," "NULL,'2026-09-23T06:30:00+02:00',NULL,NULL,NULL,'');", "INSERT INTO sleep_diary_events(revision_id,event_id,event_type,start_at,end_at) VALUES" "('slr_99999999-9999-4999-8999-999999999999','sle_1','bed_time'," "'2026-09-22T22:30:00+02:00',NULL)," "('slr_99999999-9999-4999-8999-999999999999','sle_2','sleep'," "'2026-09-23T02:10:00+02:00','2026-09-23T03:20:00+02:00')," "('slr_99999999-9999-4999-8999-999999999999','sle_3','final_get_up'," "'2026-09-23T06:20:00+02:00',NULL);" "INSERT INTO sleep_medication_intakes(revision_id,intake_id,medication_id,medication_name," "taken_at,dose_value,dose_unit,quantity,note,created_at) VALUES(" "'slr_99999999-9999-4999-8999-999999999999','mdi_1','med_1','Médicament test'," "'2026-09-22T22:40:00+02:00',NULL,NULL,2,NULL,'2026-09-22T22:40:00+02:00');", "INSERT INTO " "heart_rate_captures(capture_id,context_kind,context_id,started_at,ended_at,sensor_name," "imported_at)" " VALUES('hrc_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa','sleep'," "'sl_88888888-8888-4888-8888-888888888888','2026-09-22T22:30:00+02:00'," "'2026-09-23T06:20:00+02:00','CYCPLUS H2','2026-09-23T07:00:00+02:00');" "INSERT INTO heart_rate_samples(capture_id,sequence,observed_at,bpm,exercise_entry_id," "sensor_contact_detected,energy_expended) VALUES(" "'hrc_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',0,'2026-09-23T02:10:00+02:00',75,NULL,NULL," "NULL);", }; size_t index; for (index = 0U; index < sizeof(statements) / sizeof(statements[0]); ++index) { if (!execute_sql(database, statements[index])) { return false; } } return true; } static yyjson_doc *timeline(TrainlogDatabase *database, const char *context_id) { char *json = malloc(TRAINLOG_WEB_HEART_RATE_JSON_CAPACITY); size_t size = 0U; yyjson_doc *document = NULL; if (json == NULL) { return NULL; } if (trainlog_web_heart_rate_timeline_json( database, context_id, json, TRAINLOG_WEB_HEART_RATE_JSON_CAPACITY, &size) == TRAINLOG_STATUS_OK && size > 0U) { document = yyjson_read(json, size, 0); } free(json); return document; } int main(void) { char path[] = "/tmp/trainlog-web-heart-rate-XXXXXX"; int descriptor = mkstemp(path); TrainlogDatabase *database = NULL; yyjson_doc *document = NULL; yyjson_val *root; yyjson_val *capture; yyjson_val *samples; yyjson_val *events; yyjson_val *guidance; yyjson_val *phase; yyjson_val *calibration; int result = EXIT_FAILURE; CHECK(descriptor >= 0 && close(descriptor) == 0); CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); CHECK(seed(database->connection)); document = timeline(database, SESSION_ID); CHECK(document != NULL); root = yyjson_doc_get_root(document); CHECK(yyjson_get_bool(yyjson_obj_get(root, "available"))); capture = yyjson_obj_get(root, "capture"); CHECK(strcmp(yyjson_get_str(yyjson_obj_get(capture, "context_kind")), "cardio") == 0); CHECK(yyjson_get_int(yyjson_obj_get(capture, "sample_count")) == 2); samples = yyjson_obj_get(capture, "samples"); CHECK(yyjson_arr_size(samples) == 2U); CHECK(yyjson_arr_size(yyjson_obj_get(yyjson_arr_get(samples, 0U), "rr_1024")) == 1U); events = yyjson_obj_get(root, "events"); CHECK(yyjson_arr_size(events) == 2U); guidance = yyjson_obj_get(root, "guidance"); CHECK(yyjson_is_obj(guidance)); CHECK(strcmp(yyjson_get_str(yyjson_obj_get(guidance, "run_id")), RUN_ID) == 0); phase = yyjson_arr_get(yyjson_obj_get(guidance, "phases"), 0U); CHECK(strcmp(yyjson_get_str(yyjson_obj_get(phase, "entry_id")), ENTRY_ID) == 0); CHECK(yyjson_get_int(yyjson_obj_get(yyjson_obj_get(phase, "target"), "minimum_bpm")) == 120); CHECK(yyjson_arr_size(yyjson_obj_get(phase, "events")) == 2U); calibration = yyjson_obj_get(root, "calibration"); CHECK(yyjson_is_obj(calibration)); CHECK(strcmp(yyjson_get_str(yyjson_obj_get(calibration, "calibration_id")), CALIBRATION_ID) == 0); CHECK(yyjson_get_int(yyjson_obj_get(calibration, "observed_peak_bpm")) == 160); CHECK(yyjson_arr_size(yyjson_obj_get(calibration, "recovery")) == 1U); yyjson_doc_free(document); document = NULL; document = timeline(database, SLEEP_ID); CHECK(document != NULL); root = yyjson_doc_get_root(document); CHECK(yyjson_get_bool(yyjson_obj_get(root, "available"))); capture = yyjson_obj_get(root, "capture"); CHECK(strcmp(yyjson_get_str(yyjson_obj_get(capture, "context_kind")), "sleep") == 0); CHECK(yyjson_get_int(yyjson_obj_get(capture, "sample_count")) == 1); events = yyjson_obj_get(root, "events"); CHECK(yyjson_arr_size(events) == 4U); CHECK(strcmp(yyjson_get_str(yyjson_obj_get(yyjson_arr_get(events, 0U), "at")), "2026-09-22T22:30:00+02:00") == 0); CHECK(yyjson_is_null(yyjson_obj_get(yyjson_arr_get(events, 0U), "end_at"))); CHECK(strcmp(yyjson_get_str(yyjson_obj_get(yyjson_arr_get(events, 1U), "at")), "2026-09-23T02:10:00+02:00") == 0); CHECK(strcmp(yyjson_get_str(yyjson_obj_get(yyjson_arr_get(events, 1U), "end_at")), "2026-09-23T03:20:00+02:00") == 0); CHECK(strcmp(yyjson_get_str(yyjson_obj_get(yyjson_arr_get(events, 3U), "at")), "2026-09-22T22:40:00+02:00") == 0); CHECK(strcmp(yyjson_get_str(yyjson_obj_get(yyjson_arr_get(events, 3U), "label")), "Médicament test ×2") == 0); CHECK(yyjson_is_null(yyjson_obj_get(root, "guidance"))); CHECK(yyjson_is_null(yyjson_obj_get(root, "calibration"))); yyjson_doc_free(document); document = NULL; document = timeline(database, "se_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"); CHECK(document != NULL); root = yyjson_doc_get_root(document); CHECK(!yyjson_get_bool(yyjson_obj_get(root, "available"))); CHECK(yyjson_is_null(yyjson_obj_get(root, "capture"))); yyjson_doc_free(document); document = NULL; { char buffer[64]; size_t size = 123U; CHECK( trainlog_web_heart_rate_timeline_json(database, "bad", buffer, sizeof(buffer), &size) == TRAINLOG_STATUS_INVALID_ARGUMENT); } result = EXIT_SUCCESS; cleanup: if (document != NULL) { yyjson_doc_free(document); } if (database != NULL) { trainlog_database_close(database); } (void)unlink(path); if (result == EXIT_SUCCESS) { puts("PASS factual Web heart-rate timeline"); } return result; }