feat(core): add sleep diary domain
This commit is contained in:
parent
6948e0bbd5
commit
2565d0eafa
38 changed files with 1099 additions and 280 deletions
|
|
@ -38,8 +38,8 @@ def digest(value) -> str:
|
|||
|
||||
def require_schema(db: sqlite3.Connection) -> None:
|
||||
version = db.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28):
|
||||
raise CausalError(f"desktop schema v20-v28 required, found v{version}")
|
||||
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
|
||||
raise CausalError(f"desktop schema v20-v29 required, found v{version}")
|
||||
|
||||
|
||||
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
||||
|
|
|
|||
|
|
@ -182,8 +182,8 @@ def causal_payload(draft):
|
|||
|
||||
def require_schema(connection):
|
||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28):
|
||||
raise LifecycleError(f"desktop schema v19-v28 required, found v{version}")
|
||||
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
|
||||
raise LifecycleError(f"desktop schema v19-v29 required, found v{version}")
|
||||
|
||||
|
||||
def import_document(connection, document, own_transaction=True):
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from pathlib import Path
|
|||
from trainlog_sqlite import configure_connection
|
||||
|
||||
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28)
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29)
|
||||
MEASUREMENT_FIELDS = (
|
||||
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
||||
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
|
||||
|
|
@ -125,7 +125,7 @@ def build_export(connection, generated_at):
|
|||
"""Build the raw export using only explicitly ordered SELECT statements."""
|
||||
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
|
||||
raise ValueError("schema desktop v15-v28 requis")
|
||||
raise ValueError("schema desktop v15-v29 requis")
|
||||
|
||||
root = {
|
||||
"format": "TRAINLOG_AI_EXPORT",
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@ ID_PREFIX = "aid_"
|
|||
def ensure_publication_state(connection):
|
||||
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
||||
owned_transaction = not connection.in_transaction
|
||||
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28)
|
||||
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v18-v28 requis")
|
||||
raise ValueError("schema desktop v18-v29 requis")
|
||||
columns = {row[1] for row in connection.execute(
|
||||
"PRAGMA table_info(ai_session_drafts)")}
|
||||
if "published_at" not in columns:
|
||||
|
|
|
|||
|
|
@ -28,9 +28,9 @@ def main():
|
|||
args = parser.parse_args()
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
supported_versions = tuple(range(8, 29))
|
||||
supported_versions = tuple(range(8, 30))
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v8 à v28 requis")
|
||||
raise ValueError("schema desktop v8 à v29 requis")
|
||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||
known_equipment.update(row[0] for row in connection.execute(
|
||||
"SELECT equipment_id FROM custom_equipment"))
|
||||
|
|
|
|||
|
|
@ -24,9 +24,9 @@ def main(complete_causal_envelope=False):
|
|||
connection = connect_database(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
supported_versions = tuple(range(8, 29))
|
||||
supported_versions = tuple(range(8, 30))
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v8 à v28 requis")
|
||||
raise ValueError("schema desktop v8 à v29 requis")
|
||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
||||
raise ValueError("causal equipment protection requires the staged artifact")
|
||||
equipment = [dict(row) for row in connection.execute(
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ def main():
|
|||
con = connect_database(args.database)
|
||||
try:
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
||||
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28):
|
||||
raise ValueError("schema desktop v12-v28 requis")
|
||||
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
|
||||
raise ValueError("schema desktop v12-v29 requis")
|
||||
rows = con.execute(
|
||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||
"ORDER BY source_exercise_id COLLATE BINARY"
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ def main(complete_causal_envelope=False):
|
|||
connection = connect_database(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
supported_versions = tuple(range(11, 29))
|
||||
supported_versions = tuple(range(11, 30))
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v11-v16 requis")
|
||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ def main():
|
|||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28):
|
||||
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
|
||||
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
||||
rows = con.execute("""
|
||||
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ def main():
|
|||
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
||||
# catalogue artifact is unchanged, but it must read the current
|
||||
# canonical desktop schema rather than accept a stale pre-v8 database.
|
||||
if version not in range(8, 29):
|
||||
if version not in range(8, 30):
|
||||
raise SystemExit(
|
||||
"PC_CATALOG_EXPORT=FAIL "
|
||||
f"schema={version}"
|
||||
|
|
|
|||
|
|
@ -83,9 +83,9 @@ def main(complete_causal_envelope=False):
|
|||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28):
|
||||
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
|
||||
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
|
||||
supported_versions = range(11, 29)
|
||||
supported_versions = range(11, 30)
|
||||
legacy_v2_schema = args.version == 2 and schema_version == 10
|
||||
if schema_version not in supported_versions and not legacy_v2_schema:
|
||||
raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)")
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ def nullable(row, index):
|
|||
|
||||
def export_programs(database: Path) -> dict:
|
||||
with closing(connect_database(database)) as db:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28):
|
||||
raise ValueError("desktop schema v26, v27 or v28 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29):
|
||||
raise ValueError("desktop schema v26, v27, v28 or v29 required")
|
||||
programs = []
|
||||
for row in db.execute(
|
||||
"SELECT program_id,revision_id,title,note,state,start_date,end_date,created_at,"
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ MAX_WITHDRAWALS = 128
|
|||
|
||||
|
||||
def build_export(connection: sqlite3.Connection) -> dict:
|
||||
supported_versions = (24, 25, 26, 27, 28)
|
||||
supported_versions = (24, 25, 26, 27, 28, 29)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("desktop schema v24, v25, v26, v27 or v28 required")
|
||||
raise ValueError("desktop schema v24, v25, v26, v27, v28 or v29 required")
|
||||
deliveries = []
|
||||
rows = connection.execute(
|
||||
"SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,d.state,"
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ def main(complete_causal_envelope=False):
|
|||
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
|
||||
db=connect_database(args.database)
|
||||
try:
|
||||
supported_versions = range(15, 29)
|
||||
supported_versions = range(15, 30)
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v15-v28 requis")
|
||||
raise ValueError("schema desktop v15-v29 requis")
|
||||
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact")
|
||||
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
|
||||
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()
|
||||
|
|
|
|||
|
|
@ -193,9 +193,9 @@ def import_payload(connection, payload, imported_at=None):
|
|||
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
|
||||
any failure leaves no draft, child, or import-identity row behind.
|
||||
"""
|
||||
supported_versions = range(18, 29)
|
||||
supported_versions = range(18, 30)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ImportFailure("schema desktop v18-v28 requis")
|
||||
raise ImportFailure("schema desktop v18-v29 requis")
|
||||
draft, entries = validate(payload, connection)
|
||||
digest = canonical_digest(payload)
|
||||
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
|
|
|
|||
|
|
@ -206,7 +206,7 @@ def main():
|
|||
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
supported_versions = range(8, 29)
|
||||
supported_versions = range(8, 30)
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v8 à v21 requis")
|
||||
proof_path = args.mobile_export
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ def validate(payload, reserved):
|
|||
|
||||
def apply_definitions(connection, definitions, complete_causal_envelope=False):
|
||||
"""Apply validated definitions without owning the surrounding transaction."""
|
||||
supported_versions = range(8, 29)
|
||||
supported_versions = range(8, 30)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v8 à v21 requis")
|
||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ def load(path):
|
|||
|
||||
def apply_aliases(con, aliases):
|
||||
"""Apply parsed aliases inside the caller-owned transaction."""
|
||||
supported_versions = range(12, 29)
|
||||
supported_versions = range(12, 30)
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v12-v21 requis")
|
||||
for source, canonical in aliases:
|
||||
|
|
|
|||
|
|
@ -151,9 +151,9 @@ def replace(connection, row_id, primary, secondary):
|
|||
|
||||
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
|
||||
"""Apply validated zone rows without owning transaction boundaries."""
|
||||
supported_versions = range(11, 29)
|
||||
supported_versions = range(11, 30)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ImportFailure("schema desktop v11-v28 requis")
|
||||
raise ImportFailure("schema desktop v11-v29 requis")
|
||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact")
|
||||
connection.execute("PRAGMA foreign_keys=ON");grouped={}
|
||||
# CONTRACT: resolve complete alias groups before the first write; direct
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@ def apply_profile_state(con, root, allow_pending=False):
|
|||
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
|
||||
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28):
|
||||
fail("schema desktop v17-v28 requis")
|
||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
|
||||
fail("schema desktop v17-v29 requis")
|
||||
applied=equal=ancestor=pending=0;seen=set()
|
||||
for item in root["exercises"]:
|
||||
validate(item);eid=item["exercise_id"]
|
||||
|
|
|
|||
|
|
@ -847,9 +847,9 @@ def require_supported_schema(connection):
|
|||
|
||||
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
||||
# readable for legacy artifacts and are never made to fake that table.
|
||||
if version not in range(5, 29):
|
||||
if version not in range(5, 30):
|
||||
raise ImportFailure(
|
||||
f"base desktop schema v5 à v28 attendue, version trouvée: {version}"
|
||||
f"base desktop schema v5 à v29 attendue, version trouvée: {version}"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -2002,8 +2002,8 @@ def apply_payload(connection, payload, trace_exercises=False, complete_causal_en
|
|||
require_supported_schema(connection)
|
||||
|
||||
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
|
||||
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28):
|
||||
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v28")
|
||||
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
|
||||
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v29")
|
||||
if schema_version >= 20:
|
||||
protected = {(row[0], row[1]) for row in connection.execute(
|
||||
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}
|
||||
|
|
|
|||
|
|
@ -52,9 +52,9 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
|
|||
def apply_feedback(db,root,complete_causal_envelope=False):
|
||||
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
||||
db.execute("PRAGMA foreign_keys=ON")
|
||||
supported_versions = range(15, 29)
|
||||
supported_versions = range(15, 30)
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v15-v28 requis")
|
||||
fail("schema desktop v15-v29 requis")
|
||||
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact")
|
||||
seen=set()
|
||||
for index,item in enumerate(root["exercise_feedback"]):
|
||||
|
|
|
|||
|
|
@ -87,8 +87,8 @@ def load(path: Path) -> dict:
|
|||
|
||||
|
||||
def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28):
|
||||
fail("desktop schema v27 or v28 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29):
|
||||
fail("desktop schema v27, v28 or v29 required")
|
||||
added = 0
|
||||
advanced = 0
|
||||
skipped = 0
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
#include "trainlog/model.h"
|
||||
#include "trainlog/status.h"
|
||||
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 28
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 29
|
||||
|
||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@
|
|||
* occurrence, delivery, and withdrawal identities;
|
||||
* - `pgr` for desktop Program V1 revision identities;
|
||||
* - `del` for immutable causal deletion operations.
|
||||
* - `sl`, `slr`, and `sle` for sleep diary entries, revisions, and events.
|
||||
*
|
||||
* Imported v1 documents may contain other schema-valid opaque identifiers;
|
||||
* this API defines creation policy, not import validation.
|
||||
|
|
|
|||
100
tui/include/trainlog/sleep_diary.h
Normal file
100
tui/include/trainlog/sleep_diary.h
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/* Trainlog Sleep Diary V1 domain API. */
|
||||
#ifndef TRAINLOG_SLEEP_DIARY_H
|
||||
#define TRAINLOG_SLEEP_DIARY_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#include "trainlog/database.h"
|
||||
#include "trainlog/status.h"
|
||||
|
||||
#define TRAINLOG_SLEEP_EVENTS_MAX 64U
|
||||
#define TRAINLOG_SLEEP_NOTES_MAX 16384U
|
||||
#define TRAINLOG_SLEEP_ENTRY_ID_CAPACITY 40U
|
||||
#define TRAINLOG_SLEEP_REVISION_ID_CAPACITY 41U
|
||||
#define TRAINLOG_SLEEP_EVENT_ID_CAPACITY 41U
|
||||
#define TRAINLOG_SLEEP_TIMESTAMP_CAPACITY 64U
|
||||
|
||||
typedef enum TrainlogSleepQuality {
|
||||
TRAINLOG_SLEEP_QUALITY_UNSET = 0,
|
||||
TRAINLOG_SLEEP_QUALITY_TB,
|
||||
TRAINLOG_SLEEP_QUALITY_B,
|
||||
TRAINLOG_SLEEP_QUALITY_MOY,
|
||||
TRAINLOG_SLEEP_QUALITY_M,
|
||||
TRAINLOG_SLEEP_QUALITY_TM
|
||||
} TrainlogSleepQuality;
|
||||
|
||||
typedef enum TrainlogSleepEventType {
|
||||
TRAINLOG_SLEEP_EVENT_BED_TIME = 0,
|
||||
TRAINLOG_SLEEP_EVENT_FINAL_GET_UP,
|
||||
TRAINLOG_SLEEP_EVENT_NIGHT_GET_UP,
|
||||
TRAINLOG_SLEEP_EVENT_SLEEP,
|
||||
TRAINLOG_SLEEP_EVENT_NAP,
|
||||
TRAINLOG_SLEEP_EVENT_LONG_AWAKE,
|
||||
TRAINLOG_SLEEP_EVENT_HALF_SLEEP,
|
||||
TRAINLOG_SLEEP_EVENT_DAYTIME_SLEEPINESS
|
||||
} TrainlogSleepEventType;
|
||||
|
||||
typedef struct TrainlogSleepEvent {
|
||||
char event_id[TRAINLOG_SLEEP_EVENT_ID_CAPACITY];
|
||||
TrainlogSleepEventType type;
|
||||
char start_at[TRAINLOG_SLEEP_TIMESTAMP_CAPACITY];
|
||||
char end_at[TRAINLOG_SLEEP_TIMESTAMP_CAPACITY];
|
||||
} TrainlogSleepEvent;
|
||||
|
||||
typedef struct TrainlogSleepDiaryEntry {
|
||||
char entry_id[TRAINLOG_SLEEP_ENTRY_ID_CAPACITY];
|
||||
char night_start_date[11];
|
||||
char night_end_date[11];
|
||||
char created_at[TRAINLOG_SLEEP_TIMESTAMP_CAPACITY];
|
||||
char updated_at[TRAINLOG_SLEEP_TIMESTAMP_CAPACITY];
|
||||
char revision_id[TRAINLOG_SLEEP_REVISION_ID_CAPACITY];
|
||||
char parent_revision_id[TRAINLOG_SLEEP_REVISION_ID_CAPACITY];
|
||||
TrainlogSleepQuality sleep_quality;
|
||||
TrainlogSleepQuality wake_quality;
|
||||
TrainlogSleepQuality day_form;
|
||||
char treatment_and_notes[TRAINLOG_SLEEP_NOTES_MAX + 1U];
|
||||
TrainlogSleepEvent events[TRAINLOG_SLEEP_EVENTS_MAX];
|
||||
size_t event_count;
|
||||
bool deleted;
|
||||
} TrainlogSleepDiaryEntry;
|
||||
|
||||
typedef TrainlogStatus (*TrainlogSleepDiaryVisitor)(void *context,
|
||||
const TrainlogSleepDiaryEntry *entry);
|
||||
|
||||
/* CONTRACT: timestamps are ISO-8601 instants carrying Z or a numeric offset.
|
||||
* Intervals are ordered by absolute time, never lexically or by HH:MM. */
|
||||
bool trainlog_sleep_diary_validate(const TrainlogSleepDiaryEntry *entry);
|
||||
|
||||
/* Creates one stable identity and initial immutable revision transactionally. */
|
||||
TrainlogStatus trainlog_sleep_diary_create(TrainlogDatabase *database,
|
||||
TrainlogSleepDiaryEntry *entry);
|
||||
|
||||
/* Replaces the complete current snapshot iff expected_revision is current.
|
||||
* Exact replay of an already-current resulting revision is idempotent. */
|
||||
TrainlogStatus trainlog_sleep_diary_update(TrainlogDatabase *database,
|
||||
const char *expected_revision,
|
||||
TrainlogSleepDiaryEntry *entry);
|
||||
|
||||
TrainlogStatus trainlog_sleep_diary_get(TrainlogDatabase *database,
|
||||
const char *entry_id,
|
||||
bool include_deleted,
|
||||
TrainlogSleepDiaryEntry *output);
|
||||
|
||||
/* Dates are inclusive YYYY-MM-DD bounds; NULL means unbounded. */
|
||||
TrainlogStatus trainlog_sleep_diary_list(TrainlogDatabase *database,
|
||||
const char *start_date,
|
||||
const char *end_date,
|
||||
size_t limit,
|
||||
TrainlogSleepDiaryVisitor visitor,
|
||||
void *context);
|
||||
|
||||
/* Logical deletion is revision guarded and retained for sync replay safety. */
|
||||
TrainlogStatus
|
||||
trainlog_sleep_diary_delete(TrainlogDatabase *database,
|
||||
const char *entry_id,
|
||||
const char *expected_revision,
|
||||
const char *deleted_at,
|
||||
char output_revision[TRAINLOG_SLEEP_REVISION_ID_CAPACITY]);
|
||||
|
||||
#endif
|
||||
|
|
@ -48,6 +48,7 @@ if web_assets_enabled
|
|||
'../web/src/api/health.ts',
|
||||
'../web/src/api/dashboard.ts',
|
||||
'../web/src/api/analysis.ts',
|
||||
'../web/src/api/sleepDiary.ts',
|
||||
'../web/src/api/analysis.test.ts',
|
||||
'../web/src/analysis/messages.ts',
|
||||
'../web/src/api/dashboardLayout.ts',
|
||||
|
|
@ -77,6 +78,9 @@ if web_assets_enabled
|
|||
'../web/src/presentation/dateFormat.ts',
|
||||
'../web/src/routes/DashboardPage.tsx',
|
||||
'../web/src/routes/AnalysisPage.tsx',
|
||||
'../web/src/routes/SleepDiaryWorkspace.tsx',
|
||||
'../web/src/report/sleepDiaryPdf.ts',
|
||||
'../web/src/report/sleepDiaryPdf.test.ts',
|
||||
'../web/src/routes/AnalysisPage.test.tsx',
|
||||
'../web/src/routes/PlaceholderPage.tsx',
|
||||
'../web/src/routes/SessionsPage.tsx',
|
||||
|
|
@ -219,6 +223,7 @@ trainlog_core_sources = files(
|
|||
'src/web_sessions.c',
|
||||
'src/web_session_deletions.c',
|
||||
'src/web_programs.c',
|
||||
'src/web_sleep.c',
|
||||
'src/web_exercises.c',
|
||||
'src/duration.c',
|
||||
'src/id.c',
|
||||
|
|
@ -229,6 +234,7 @@ trainlog_core_sources = files(
|
|||
'src/generation_mtp.c',
|
||||
'src/reps.c',
|
||||
'src/session_generation.c',
|
||||
'src/sleep_diary.c',
|
||||
'src/statistics.c',
|
||||
'src/measured_max.c',
|
||||
'src/sync.c',
|
||||
|
|
@ -801,6 +807,22 @@ test_schema_v28_migration = executable(
|
|||
|
||||
test('schema_v28_migration', test_schema_v28_migration)
|
||||
|
||||
test_sleep_diary = executable(
|
||||
'test_sleep_diary',
|
||||
'tests/test_sleep_diary.c',
|
||||
dependencies: trainlog_core_dep,
|
||||
c_args: strict_c_args,
|
||||
)
|
||||
test('sleep_diary', test_sleep_diary)
|
||||
|
||||
test_schema_v29_migration = executable(
|
||||
'test_schema_v29_migration',
|
||||
'tests/test_schema_v29_migration.c',
|
||||
dependencies: trainlog_core_dep,
|
||||
c_args: strict_c_args,
|
||||
)
|
||||
test('schema_v29_migration', test_schema_v29_migration)
|
||||
|
||||
test_schema_v7_migration = executable(
|
||||
'test_schema_v7_migration',
|
||||
'tests/test_schema_v7_migration.c',
|
||||
|
|
@ -875,6 +897,11 @@ test(
|
|||
args: [meson.project_source_root() / 'tests/test_sync_generation_exchange.py'],
|
||||
timeout: 120,
|
||||
)
|
||||
test(
|
||||
'sleep_diary_exchange',
|
||||
find_program('python3'),
|
||||
args: [meson.project_source_root() / 'tests/test_sleep_diary_exchange.py'],
|
||||
)
|
||||
test(
|
||||
'session_preparation_export',
|
||||
find_program('python3'),
|
||||
|
|
|
|||
|
|
@ -1274,6 +1274,41 @@ static const char *const MIGRATE_V27_TO_V28_SQL =
|
|||
"ON program_session_executions(program_id,state);"
|
||||
"PRAGMA user_version=28;COMMIT;PRAGMA foreign_keys=ON;";
|
||||
|
||||
/* WHY: sleep observations need minute-precise, timezone-aware facts that can
|
||||
* later be correlated with physiological samples; a rendered 18:00 timeline
|
||||
* is not a persistence model. CONTRACT: one stable diary identity owns a
|
||||
* bounded causal revision chain and zero or more typed points/intervals.
|
||||
* INVARIANT: wall-clock timestamps never order concurrent peer revisions and
|
||||
* logical deletion retains the last revision for causal replay protection. */
|
||||
static const char *const MIGRATE_V28_TO_V29_SQL =
|
||||
"BEGIN IMMEDIATE;"
|
||||
"CREATE TABLE IF NOT EXISTS sleep_diary_entries("
|
||||
"entry_id TEXT PRIMARY KEY,night_start_date TEXT NOT NULL,night_end_date TEXT NOT NULL,"
|
||||
"created_at TEXT NOT NULL,updated_at TEXT NOT NULL,current_revision_id TEXT NOT NULL,"
|
||||
"deleted INTEGER NOT NULL DEFAULT 0 CHECK(deleted IN(0,1)),"
|
||||
"CHECK(entry_id GLOB 'sl_*'),CHECK(night_start_date<night_end_date));"
|
||||
"CREATE TABLE IF NOT EXISTS sleep_diary_revisions("
|
||||
"revision_id TEXT PRIMARY KEY,entry_id TEXT NOT NULL REFERENCES sleep_diary_entries(entry_id) "
|
||||
"ON DELETE RESTRICT,parent_revision_id TEXT,created_at TEXT NOT NULL,"
|
||||
"sleep_quality TEXT CHECK(sleep_quality IN('TB','B','Moy','M','TM')),"
|
||||
"wake_quality TEXT CHECK(wake_quality IN('TB','B','Moy','M','TM')),"
|
||||
"day_form TEXT CHECK(day_form IN('TB','B','Moy','M','TM')),"
|
||||
"treatment_and_notes TEXT,"
|
||||
"CHECK(treatment_and_notes IS NULL OR length(CAST(treatment_and_notes AS BLOB))<=16384),"
|
||||
"UNIQUE(entry_id,revision_id));"
|
||||
"CREATE TABLE IF NOT EXISTS sleep_diary_events("
|
||||
"revision_id TEXT NOT NULL REFERENCES sleep_diary_revisions(revision_id) ON DELETE RESTRICT,"
|
||||
"event_id TEXT NOT NULL,event_type TEXT NOT NULL CHECK(event_type IN("
|
||||
"'bed_time','final_get_up','night_get_up','sleep','nap','long_awake','half_sleep',"
|
||||
"'daytime_sleepiness')),start_at TEXT NOT NULL,end_at TEXT,"
|
||||
"CHECK((event_type IN('bed_time','final_get_up','night_get_up','daytime_sleepiness') AND "
|
||||
"end_at IS NULL) OR (event_type IN('sleep','nap','long_awake','half_sleep') AND "
|
||||
"end_at IS NOT NULL)),PRIMARY KEY(revision_id,event_id));"
|
||||
"CREATE INDEX IF NOT EXISTS sleep_diary_entries_dates ON "
|
||||
"sleep_diary_entries(night_start_date,deleted);"
|
||||
"CREATE INDEX IF NOT EXISTS sleep_diary_events_time ON sleep_diary_events(start_at,end_at);"
|
||||
"PRAGMA user_version=29;COMMIT;";
|
||||
|
||||
/* CONTRACT: migration fixtures may retain additive v25 columns while
|
||||
* deliberately lowering user_version. Add each provenance column only when
|
||||
* absent; a genuine v24 database still receives both before opening returns. */
|
||||
|
|
@ -2063,7 +2098,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
|||
} else if (version == 11 || version == 12 || version == 13 || version == 14 || version == 15 ||
|
||||
version == 16 || version == 17 || version == 18 || version == 19 || version == 20 ||
|
||||
version == 21 || version == 22 || version == 23 || version == 24 || version == 25 ||
|
||||
version == 26 || version == 27 || version == 28) {
|
||||
version == 26 || version == 27 || version == 28 || version == 29) {
|
||||
status = TRAINLOG_STATUS_OK;
|
||||
} else {
|
||||
if (version == 1) {
|
||||
|
|
@ -2205,6 +2240,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
|||
if (status == TRAINLOG_STATUS_OK && version < 28) {
|
||||
status = execute_sql(database, MIGRATE_V27_TO_V28_SQL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK && version < 29) {
|
||||
status = execute_sql(database, MIGRATE_V28_TO_V29_SQL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = ensure_v18_ai_draft_publication_state(database);
|
||||
}
|
||||
|
|
@ -2224,7 +2262,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
|||
if (status != TRAINLOG_STATUS_OK) {
|
||||
set_open_diagnostic(output_diagnostic,
|
||||
output_diagnostic_capacity,
|
||||
version == 0 ? "create schema v28" : "migrate database to schema v28",
|
||||
version == 0 ? "create schema v29" : "migrate database to schema v29",
|
||||
database->connection,
|
||||
SQLITE_ERROR);
|
||||
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ static bool prefix_is_supported(const char *prefix) {
|
|||
return strcmp(prefix, "ex") == 0 || strcmp(prefix, "se") == 0 || strcmp(prefix, "bo") == 0 ||
|
||||
strcmp(prefix, "sy") == 0 || strcmp(prefix, "sxe") == 0 || strcmp(prefix, "sp") == 0 ||
|
||||
strcmp(prefix, "spr") == 0 || strcmp(prefix, "spe") == 0 || strcmp(prefix, "spd") == 0 ||
|
||||
strcmp(prefix, "spw") == 0 || strcmp(prefix, "pgr") == 0 || strcmp(prefix, "del") == 0;
|
||||
strcmp(prefix, "spw") == 0 || strcmp(prefix, "pgr") == 0 || strcmp(prefix, "del") == 0 ||
|
||||
strcmp(prefix, "sl") == 0 || strcmp(prefix, "slr") == 0 || strcmp(prefix, "sle") == 0;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_id_generate(const char *prefix, char *output, size_t output_size) {
|
||||
|
|
|
|||
586
tui/src/sleep_diary.c
Normal file
586
tui/src/sleep_diary.c
Normal file
|
|
@ -0,0 +1,586 @@
|
|||
#include "trainlog/sleep_diary.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include "database_internal.h"
|
||||
#include "timestamp.h"
|
||||
#include "trainlog/id.h"
|
||||
|
||||
static bool date_valid(const char *value) {
|
||||
TrainlogTimestampKey key;
|
||||
char timestamp[32];
|
||||
int written;
|
||||
if (value == NULL || strlen(value) != 10U) {
|
||||
return false;
|
||||
}
|
||||
written = snprintf(timestamp, sizeof(timestamp), "%sT00:00:00Z", value);
|
||||
return written == 20 && trainlog_timestamp_parse(timestamp, 20U, &key);
|
||||
}
|
||||
|
||||
static bool id_valid(const char *value, const char *prefix, size_t capacity) {
|
||||
size_t prefix_size = strlen(prefix);
|
||||
return value != NULL && strlen(value) < capacity && strncmp(value, prefix, prefix_size) == 0 &&
|
||||
value[prefix_size] == '_' &&
|
||||
strlen(value + prefix_size + 1U) == TRAINLOG_UUID_TEXT_LENGTH;
|
||||
}
|
||||
|
||||
static const char *quality_text(TrainlogSleepQuality value) {
|
||||
static const char *const values[] = {NULL, "TB", "B", "Moy", "M", "TM"};
|
||||
return value >= TRAINLOG_SLEEP_QUALITY_UNSET && value <= TRAINLOG_SLEEP_QUALITY_TM
|
||||
? values[value]
|
||||
: NULL;
|
||||
}
|
||||
|
||||
static bool quality_parse(const unsigned char *value, TrainlogSleepQuality *output) {
|
||||
size_t index;
|
||||
if (value == NULL) {
|
||||
*output = TRAINLOG_SLEEP_QUALITY_UNSET;
|
||||
return true;
|
||||
}
|
||||
for (index = 1U; index <= (size_t)TRAINLOG_SLEEP_QUALITY_TM; ++index) {
|
||||
if (strcmp((const char *)value, quality_text((TrainlogSleepQuality)index)) == 0) {
|
||||
*output = (TrainlogSleepQuality)index;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static const char *event_text(TrainlogSleepEventType type) {
|
||||
static const char *const values[] = {"bed_time",
|
||||
"final_get_up",
|
||||
"night_get_up",
|
||||
"sleep",
|
||||
"nap",
|
||||
"long_awake",
|
||||
"half_sleep",
|
||||
"daytime_sleepiness"};
|
||||
return type >= TRAINLOG_SLEEP_EVENT_BED_TIME && type <= TRAINLOG_SLEEP_EVENT_DAYTIME_SLEEPINESS
|
||||
? values[type]
|
||||
: NULL;
|
||||
}
|
||||
|
||||
static bool event_parse(const unsigned char *value, TrainlogSleepEventType *output) {
|
||||
int index;
|
||||
if (value == NULL) {
|
||||
return false;
|
||||
}
|
||||
for (index = 0; index <= (int)TRAINLOG_SLEEP_EVENT_DAYTIME_SLEEPINESS; ++index) {
|
||||
if (strcmp((const char *)value, event_text((TrainlogSleepEventType)index)) == 0) {
|
||||
*output = (TrainlogSleepEventType)index;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool point_type(TrainlogSleepEventType type) {
|
||||
return type == TRAINLOG_SLEEP_EVENT_BED_TIME || type == TRAINLOG_SLEEP_EVENT_FINAL_GET_UP ||
|
||||
type == TRAINLOG_SLEEP_EVENT_NIGHT_GET_UP ||
|
||||
type == TRAINLOG_SLEEP_EVENT_DAYTIME_SLEEPINESS;
|
||||
}
|
||||
|
||||
static bool timestamp_valid(const char *value, TrainlogTimestampKey *key) {
|
||||
size_t length = value == NULL ? 0U : strlen(value);
|
||||
return length > 0U && length < TRAINLOG_SLEEP_TIMESTAMP_CAPACITY &&
|
||||
trainlog_timestamp_parse(value, length, key);
|
||||
}
|
||||
|
||||
bool trainlog_sleep_diary_validate(const TrainlogSleepDiaryEntry *entry) {
|
||||
size_t index, other;
|
||||
TrainlogTimestampKey created, updated;
|
||||
if (entry == NULL || !date_valid(entry->night_start_date) ||
|
||||
!date_valid(entry->night_end_date) ||
|
||||
strcmp(entry->night_start_date, entry->night_end_date) >= 0 ||
|
||||
!timestamp_valid(entry->created_at, &created) ||
|
||||
!timestamp_valid(entry->updated_at, &updated) ||
|
||||
trainlog_timestamp_compare(&updated, &created) < 0 ||
|
||||
entry->sleep_quality > TRAINLOG_SLEEP_QUALITY_TM ||
|
||||
entry->wake_quality > TRAINLOG_SLEEP_QUALITY_TM ||
|
||||
entry->day_form > TRAINLOG_SLEEP_QUALITY_TM ||
|
||||
strlen(entry->treatment_and_notes) > TRAINLOG_SLEEP_NOTES_MAX ||
|
||||
entry->event_count > TRAINLOG_SLEEP_EVENTS_MAX) {
|
||||
return false;
|
||||
}
|
||||
for (index = 0U; index < entry->event_count; ++index) {
|
||||
const TrainlogSleepEvent *event = &entry->events[index];
|
||||
TrainlogTimestampKey start, end;
|
||||
bool point = point_type(event->type);
|
||||
if (!id_valid(event->event_id, "sle", sizeof(event->event_id)) ||
|
||||
event_text(event->type) == NULL || !timestamp_valid(event->start_at, &start) ||
|
||||
(point ? event->end_at[0] != '\0'
|
||||
: !timestamp_valid(event->end_at, &end) ||
|
||||
trainlog_timestamp_compare(&end, &start) <= 0)) {
|
||||
return false;
|
||||
}
|
||||
for (other = index + 1U; other < entry->event_count; ++other) {
|
||||
if (strcmp(event->event_id, entry->events[other].event_id) == 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static void rollback(sqlite3 *database) {
|
||||
(void)sqlite3_exec(database, "ROLLBACK", NULL, NULL, NULL);
|
||||
}
|
||||
|
||||
static TrainlogStatus insert_revision(TrainlogDatabase *database,
|
||||
const TrainlogSleepDiaryEntry *entry) {
|
||||
static const char revision_sql[] =
|
||||
"INSERT INTO sleep_diary_revisions(revision_id,entry_id,parent_revision_id,created_at,"
|
||||
"sleep_quality,wake_quality,day_form,treatment_and_notes) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)";
|
||||
static const char event_sql[] =
|
||||
"INSERT INTO sleep_diary_events(revision_id,event_id,event_type,start_at,end_at) "
|
||||
"VALUES(?1,?2,?3,?4,?5)";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
size_t index;
|
||||
int result = sqlite3_prepare_v2(database->connection, revision_sql, -1, &statement, NULL);
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 1, entry->revision_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 2, entry->entry_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result =
|
||||
entry->parent_revision_id[0] == '\0'
|
||||
? sqlite3_bind_null(statement, 3)
|
||||
: sqlite3_bind_text(statement, 3, entry->parent_revision_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 4, entry->updated_at, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = entry->sleep_quality == TRAINLOG_SLEEP_QUALITY_UNSET
|
||||
? sqlite3_bind_null(statement, 5)
|
||||
: sqlite3_bind_text(
|
||||
statement, 5, quality_text(entry->sleep_quality), -1, SQLITE_STATIC);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = entry->wake_quality == TRAINLOG_SLEEP_QUALITY_UNSET
|
||||
? sqlite3_bind_null(statement, 6)
|
||||
: sqlite3_bind_text(
|
||||
statement, 6, quality_text(entry->wake_quality), -1, SQLITE_STATIC);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result =
|
||||
entry->day_form == TRAINLOG_SLEEP_QUALITY_UNSET
|
||||
? sqlite3_bind_null(statement, 7)
|
||||
: sqlite3_bind_text(statement, 7, quality_text(entry->day_form), -1, SQLITE_STATIC);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 8, entry->treatment_and_notes, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK
|
||||
: sqlite3_errcode(database->connection);
|
||||
}
|
||||
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_OK) {
|
||||
result = SQLITE_ERROR;
|
||||
}
|
||||
if (result != SQLITE_OK) {
|
||||
return result == SQLITE_CONSTRAINT ? TRAINLOG_STATUS_CONFLICT
|
||||
: TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
for (index = 0U; index < entry->event_count; ++index) {
|
||||
const TrainlogSleepEvent *event = &entry->events[index];
|
||||
statement = NULL;
|
||||
result = sqlite3_prepare_v2(database->connection, event_sql, -1, &statement, NULL);
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 1, entry->revision_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 2, event->event_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 3, event_text(event->type), -1, SQLITE_STATIC);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 4, event->start_at, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = point_type(event->type)
|
||||
? sqlite3_bind_null(statement, 5)
|
||||
: sqlite3_bind_text(statement, 5, event->end_at, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK
|
||||
: sqlite3_errcode(database->connection);
|
||||
}
|
||||
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_OK) {
|
||||
result = SQLITE_ERROR;
|
||||
}
|
||||
if (result != SQLITE_OK) {
|
||||
return result == SQLITE_CONSTRAINT ? TRAINLOG_STATUS_CONFLICT
|
||||
: TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
}
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_sleep_diary_create(TrainlogDatabase *database,
|
||||
TrainlogSleepDiaryEntry *entry) {
|
||||
static const char sql[] = "INSERT INTO sleep_diary_entries VALUES(?1,?2,?3,?4,?5,?6,0)";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
TrainlogStatus status;
|
||||
int result;
|
||||
if (database == NULL || entry == NULL || entry->deleted || entry->entry_id[0] != '\0' ||
|
||||
entry->revision_id[0] != '\0') {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
if (trainlog_id_generate("sl", entry->entry_id, sizeof(entry->entry_id)) !=
|
||||
TRAINLOG_STATUS_OK ||
|
||||
trainlog_id_generate("slr", entry->revision_id, sizeof(entry->revision_id)) !=
|
||||
TRAINLOG_STATUS_OK ||
|
||||
!trainlog_sleep_diary_validate(entry)) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) {
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
result = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 1, entry->entry_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 2, entry->night_start_date, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 3, entry->night_end_date, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 4, entry->created_at, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 5, entry->updated_at, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 6, entry->revision_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK
|
||||
: sqlite3_errcode(database->connection);
|
||||
}
|
||||
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_OK) {
|
||||
result = SQLITE_ERROR;
|
||||
}
|
||||
status = result == SQLITE_OK ? insert_revision(database, entry)
|
||||
: (result == SQLITE_CONSTRAINT ? TRAINLOG_STATUS_CONFLICT
|
||||
: TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
if (status == TRAINLOG_STATUS_OK &&
|
||||
sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) {
|
||||
return status;
|
||||
}
|
||||
rollback(database->connection);
|
||||
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_sleep_diary_update(TrainlogDatabase *database,
|
||||
const char *expected_revision,
|
||||
TrainlogSleepDiaryEntry *entry) {
|
||||
static const char sql[] =
|
||||
"UPDATE sleep_diary_entries SET "
|
||||
"night_start_date=?1,night_end_date=?2,updated_at=?3,current_revision_id=?4 WHERE "
|
||||
"entry_id=?5 AND current_revision_id=?6 AND deleted=0";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
TrainlogStatus status;
|
||||
char next[TRAINLOG_SLEEP_REVISION_ID_CAPACITY];
|
||||
int result;
|
||||
if (database == NULL || entry == NULL ||
|
||||
!id_valid(entry->entry_id, "sl", sizeof(entry->entry_id)) ||
|
||||
!id_valid(expected_revision, "slr", TRAINLOG_SLEEP_REVISION_ID_CAPACITY) ||
|
||||
entry->deleted) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
if (strcmp(entry->revision_id, expected_revision) != 0 ||
|
||||
trainlog_id_generate("slr", next, sizeof(next)) != TRAINLOG_STATUS_OK) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
(void)snprintf(
|
||||
entry->parent_revision_id, sizeof(entry->parent_revision_id), "%s", expected_revision);
|
||||
(void)snprintf(entry->revision_id, sizeof(entry->revision_id), "%s", next);
|
||||
if (!trainlog_sleep_diary_validate(entry)) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) {
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
status = insert_revision(database, entry);
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
result = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 1, entry->night_start_date, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 2, entry->night_end_date, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 3, entry->updated_at, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 4, entry->revision_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 5, entry->entry_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 6, expected_revision, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK
|
||||
: sqlite3_errcode(database->connection);
|
||||
}
|
||||
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_OK) {
|
||||
result = SQLITE_ERROR;
|
||||
}
|
||||
status = result != SQLITE_OK ? TRAINLOG_STATUS_DATABASE_ERROR
|
||||
: sqlite3_changes(database->connection) == 1 ? TRAINLOG_STATUS_OK
|
||||
: TRAINLOG_STATUS_CONFLICT;
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK &&
|
||||
sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) {
|
||||
return status;
|
||||
}
|
||||
rollback(database->connection);
|
||||
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status;
|
||||
}
|
||||
|
||||
static TrainlogStatus load_entry(TrainlogDatabase *database,
|
||||
const char *entry_id,
|
||||
bool include_deleted,
|
||||
TrainlogSleepDiaryEntry *output) {
|
||||
static const char root_sql[] =
|
||||
"SELECT "
|
||||
"e.entry_id,e.night_start_date,e.night_end_date,e.created_at,e.updated_at,e.current_"
|
||||
"revision_id,e.deleted,r.parent_revision_id,r.sleep_quality,r.wake_quality,r.day_form,r."
|
||||
"treatment_and_notes FROM sleep_diary_entries e JOIN sleep_diary_revisions r ON "
|
||||
"r.revision_id=e.current_revision_id WHERE e.entry_id=?1 AND (?2 OR e.deleted=0)";
|
||||
static const char events_sql[] =
|
||||
"SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events WHERE revision_id=?1 "
|
||||
"ORDER BY start_at COLLATE BINARY,event_id COLLATE BINARY";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
int result;
|
||||
memset(output, 0, sizeof(*output));
|
||||
result = sqlite3_prepare_v2(database->connection, root_sql, -1, &statement, NULL);
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 1, entry_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_int(statement, 2, include_deleted ? 1 : 0);
|
||||
}
|
||||
if (result != SQLITE_OK || sqlite3_step(statement) != SQLITE_ROW) {
|
||||
if (statement != NULL) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
}
|
||||
return result == SQLITE_OK ? TRAINLOG_STATUS_NOT_FOUND : TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
#define COPY_COLUMN(field, column) \
|
||||
(void)snprintf(output->field, \
|
||||
sizeof(output->field), \
|
||||
"%s", \
|
||||
sqlite3_column_text(statement, column) == NULL \
|
||||
? "" \
|
||||
: (const char *)sqlite3_column_text(statement, column))
|
||||
COPY_COLUMN(entry_id, 0);
|
||||
COPY_COLUMN(night_start_date, 1);
|
||||
COPY_COLUMN(night_end_date, 2);
|
||||
COPY_COLUMN(created_at, 3);
|
||||
COPY_COLUMN(updated_at, 4);
|
||||
COPY_COLUMN(revision_id, 5);
|
||||
output->deleted = sqlite3_column_int(statement, 6) != 0;
|
||||
COPY_COLUMN(parent_revision_id, 7);
|
||||
#undef COPY_COLUMN
|
||||
if (!quality_parse(sqlite3_column_text(statement, 8), &output->sleep_quality) ||
|
||||
!quality_parse(sqlite3_column_text(statement, 9), &output->wake_quality) ||
|
||||
!quality_parse(sqlite3_column_text(statement, 10), &output->day_form)) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
(void)snprintf(output->treatment_and_notes,
|
||||
sizeof(output->treatment_and_notes),
|
||||
"%s",
|
||||
sqlite3_column_text(statement, 11) == NULL
|
||||
? ""
|
||||
: (const char *)sqlite3_column_text(statement, 11));
|
||||
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
result = sqlite3_prepare_v2(database->connection, events_sql, -1, &statement, NULL);
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 1, output->revision_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_step(statement);
|
||||
}
|
||||
while (result == SQLITE_ROW) {
|
||||
TrainlogSleepEvent *event;
|
||||
if (output->event_count == TRAINLOG_SLEEP_EVENTS_MAX) {
|
||||
result = SQLITE_TOOBIG;
|
||||
break;
|
||||
}
|
||||
event = &output->events[output->event_count++];
|
||||
(void)snprintf(
|
||||
event->event_id, sizeof(event->event_id), "%s", sqlite3_column_text(statement, 0));
|
||||
if (!event_parse(sqlite3_column_text(statement, 1), &event->type)) {
|
||||
result = SQLITE_MISMATCH;
|
||||
break;
|
||||
}
|
||||
(void)snprintf(
|
||||
event->start_at, sizeof(event->start_at), "%s", sqlite3_column_text(statement, 2));
|
||||
(void)snprintf(event->end_at,
|
||||
sizeof(event->end_at),
|
||||
"%s",
|
||||
sqlite3_column_text(statement, 3) == NULL
|
||||
? ""
|
||||
: (const char *)sqlite3_column_text(statement, 3));
|
||||
result = sqlite3_step(statement);
|
||||
}
|
||||
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_DONE) {
|
||||
result = SQLITE_ERROR;
|
||||
}
|
||||
return result == SQLITE_DONE && trainlog_sleep_diary_validate(output)
|
||||
? TRAINLOG_STATUS_OK
|
||||
: TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_sleep_diary_get(TrainlogDatabase *database,
|
||||
const char *entry_id,
|
||||
bool include_deleted,
|
||||
TrainlogSleepDiaryEntry *output) {
|
||||
if (database == NULL || output == NULL ||
|
||||
!id_valid(entry_id, "sl", TRAINLOG_SLEEP_ENTRY_ID_CAPACITY)) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
return load_entry(database, entry_id, include_deleted, output);
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_sleep_diary_list(TrainlogDatabase *database,
|
||||
const char *start_date,
|
||||
const char *end_date,
|
||||
size_t limit,
|
||||
TrainlogSleepDiaryVisitor visitor,
|
||||
void *context) {
|
||||
static const char sql[] =
|
||||
"SELECT entry_id FROM sleep_diary_entries WHERE deleted=0 AND (?1 IS NULL OR "
|
||||
"night_start_date>=?1) AND (?2 IS NULL OR night_start_date<=?2) ORDER BY night_start_date "
|
||||
"DESC,entry_id COLLATE BINARY LIMIT ?3";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
int result;
|
||||
if (database == NULL || visitor == NULL || limit == 0U || limit > 3660U ||
|
||||
(start_date != NULL && !date_valid(start_date)) ||
|
||||
(end_date != NULL && !date_valid(end_date)) ||
|
||||
(start_date != NULL && end_date != NULL && strcmp(start_date, end_date) > 0)) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
result = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
|
||||
if (result == SQLITE_OK) {
|
||||
result = start_date == NULL
|
||||
? sqlite3_bind_null(statement, 1)
|
||||
: sqlite3_bind_text(statement, 1, start_date, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = end_date == NULL ? sqlite3_bind_null(statement, 2)
|
||||
: sqlite3_bind_text(statement, 2, end_date, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_int64(statement, 3, (sqlite3_int64)limit);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_step(statement);
|
||||
}
|
||||
while (result == SQLITE_ROW) {
|
||||
TrainlogSleepDiaryEntry entry;
|
||||
TrainlogStatus status =
|
||||
load_entry(database, (const char *)sqlite3_column_text(statement, 0), false, &entry);
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = visitor(context, &entry);
|
||||
}
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return status;
|
||||
}
|
||||
result = sqlite3_step(statement);
|
||||
}
|
||||
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_DONE) {
|
||||
result = SQLITE_ERROR;
|
||||
}
|
||||
return result == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
|
||||
TrainlogStatus
|
||||
trainlog_sleep_diary_delete(TrainlogDatabase *database,
|
||||
const char *entry_id,
|
||||
const char *expected_revision,
|
||||
const char *deleted_at,
|
||||
char output_revision[TRAINLOG_SLEEP_REVISION_ID_CAPACITY]) {
|
||||
static const char sql[] =
|
||||
"UPDATE sleep_diary_entries SET deleted=1,updated_at=?1,current_revision_id=?2 WHERE "
|
||||
"entry_id=?3 AND current_revision_id=?4 AND deleted=0";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
TrainlogSleepDiaryEntry entry;
|
||||
TrainlogStatus status;
|
||||
int result;
|
||||
if (database == NULL || output_revision == NULL ||
|
||||
!id_valid(entry_id, "sl", TRAINLOG_SLEEP_ENTRY_ID_CAPACITY) ||
|
||||
!id_valid(expected_revision, "slr", TRAINLOG_SLEEP_REVISION_ID_CAPACITY)) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
status = load_entry(database, entry_id, false, &entry);
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
return status;
|
||||
}
|
||||
if (strcmp(entry.revision_id, expected_revision) != 0) {
|
||||
return TRAINLOG_STATUS_CONFLICT;
|
||||
}
|
||||
(void)snprintf(
|
||||
entry.parent_revision_id, sizeof(entry.parent_revision_id), "%s", expected_revision);
|
||||
if (trainlog_id_generate("slr", entry.revision_id, sizeof(entry.revision_id)) !=
|
||||
TRAINLOG_STATUS_OK ||
|
||||
!timestamp_valid(deleted_at, &(TrainlogTimestampKey){0})) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
(void)snprintf(entry.updated_at, sizeof(entry.updated_at), "%s", deleted_at);
|
||||
entry.deleted = true;
|
||||
if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) {
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
status = insert_revision(database, &entry);
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
result = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 1, deleted_at, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 2, entry.revision_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 3, entry_id, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_bind_text(statement, 4, expected_revision, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
if (result == SQLITE_OK) {
|
||||
result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK
|
||||
: sqlite3_errcode(database->connection);
|
||||
}
|
||||
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_OK) {
|
||||
result = SQLITE_ERROR;
|
||||
}
|
||||
status = result != SQLITE_OK ? TRAINLOG_STATUS_DATABASE_ERROR
|
||||
: sqlite3_changes(database->connection) == 1 ? TRAINLOG_STATUS_OK
|
||||
: TRAINLOG_STATUS_CONFLICT;
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK &&
|
||||
sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) {
|
||||
(void)snprintf(
|
||||
output_revision, TRAINLOG_SLEEP_REVISION_ID_CAPACITY, "%s", entry.revision_id);
|
||||
return status;
|
||||
}
|
||||
rollback(database->connection);
|
||||
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status;
|
||||
}
|
||||
|
|
@ -11,125 +11,69 @@
|
|||
#include "trainlog/database.h"
|
||||
#include "trainlog/id.h"
|
||||
|
||||
#define CHECK(condition) \
|
||||
do { \
|
||||
if (!(condition)) { \
|
||||
(void)fprintf( \
|
||||
stderr, \
|
||||
"CHECK failed at %s:%d: %s\n", \
|
||||
__FILE__, \
|
||||
__LINE__, \
|
||||
#condition \
|
||||
); \
|
||||
return false; \
|
||||
} \
|
||||
#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_database_open_and_schema(void)
|
||||
{
|
||||
static bool test_database_open_and_schema(void) {
|
||||
TrainlogDatabase *database = NULL;
|
||||
int schema_version = 0;
|
||||
int foreign_keys = 0;
|
||||
|
||||
CHECK(
|
||||
trainlog_database_open(":memory:", &database) ==
|
||||
TRAINLOG_STATUS_OK
|
||||
);
|
||||
CHECK(
|
||||
trainlog_database_schema_version(database, &schema_version) ==
|
||||
TRAINLOG_STATUS_OK
|
||||
);
|
||||
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_schema_version(database, &schema_version) == TRAINLOG_STATUS_OK);
|
||||
CHECK(schema_version == TRAINLOG_DATABASE_SCHEMA_VERSION);
|
||||
CHECK(
|
||||
trainlog_database_foreign_keys_enabled(database, &foreign_keys) ==
|
||||
TRAINLOG_STATUS_OK
|
||||
);
|
||||
CHECK(trainlog_database_foreign_keys_enabled(database, &foreign_keys) == TRAINLOG_STATUS_OK);
|
||||
CHECK(foreign_keys == 1);
|
||||
|
||||
trainlog_database_close(database);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool test_database_open_diagnostic(void)
|
||||
{
|
||||
static bool test_database_open_diagnostic(void) {
|
||||
TrainlogDatabase *database = NULL;
|
||||
char diagnostic[256];
|
||||
|
||||
/* CONTRACT: a launcher must retain the stable status code while showing
|
||||
* the SQLite operation that blocked access to the user's real database. */
|
||||
CHECK(
|
||||
trainlog_database_open_with_diagnostic(
|
||||
"/",
|
||||
&database,
|
||||
diagnostic,
|
||||
sizeof(diagnostic)
|
||||
) == TRAINLOG_STATUS_DATABASE_ERROR
|
||||
);
|
||||
CHECK(trainlog_database_open_with_diagnostic("/", &database, diagnostic, sizeof(diagnostic)) ==
|
||||
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strstr(diagnostic, "open database:") != NULL);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool test_generated_ids(void)
|
||||
{
|
||||
static bool test_generated_ids(void) {
|
||||
char first[TRAINLOG_GENERATED_ID_CAPACITY];
|
||||
char second[TRAINLOG_GENERATED_ID_CAPACITY];
|
||||
char sync_id[TRAINLOG_GENERATED_ID_CAPACITY];
|
||||
char entry_id[TRAINLOG_GENERATED_ID_CAPACITY];
|
||||
char v1_exact_capacity[2U + 1U + TRAINLOG_UUID_TEXT_LENGTH + 1U];
|
||||
|
||||
CHECK(
|
||||
trainlog_id_generate("ex", first, sizeof(first)) ==
|
||||
TRAINLOG_STATUS_OK
|
||||
);
|
||||
CHECK(
|
||||
trainlog_id_generate("ex", second, sizeof(second)) ==
|
||||
TRAINLOG_STATUS_OK
|
||||
);
|
||||
CHECK(trainlog_id_generate("ex", first, sizeof(first)) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_id_generate("ex", second, sizeof(second)) == TRAINLOG_STATUS_OK);
|
||||
CHECK(strncmp(first, "ex_", 3U) == 0);
|
||||
CHECK(strlen(first) == 2U + 1U + TRAINLOG_UUID_TEXT_LENGTH);
|
||||
CHECK(strcmp(first, second) != 0);
|
||||
CHECK(first[3U + 14U] == '4');
|
||||
/* ABI behavior: the new longer occurrence prefix must not invalidate the
|
||||
* exact buffer size historically sufficient for two-character prefixes. */
|
||||
CHECK(
|
||||
trainlog_id_generate(
|
||||
"se",
|
||||
v1_exact_capacity,
|
||||
sizeof(v1_exact_capacity)
|
||||
) == TRAINLOG_STATUS_OK
|
||||
);
|
||||
CHECK(trainlog_id_generate("se", v1_exact_capacity, sizeof(v1_exact_capacity)) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
|
||||
/* TRAINLOG_SYNC_ID_PREFIX_TEST */
|
||||
CHECK(
|
||||
trainlog_id_generate(
|
||||
"sy",
|
||||
sync_id,
|
||||
sizeof(sync_id)
|
||||
) == TRAINLOG_STATUS_OK
|
||||
);
|
||||
CHECK(trainlog_id_generate("sy", sync_id, sizeof(sync_id)) == TRAINLOG_STATUS_OK);
|
||||
|
||||
CHECK(
|
||||
strncmp(
|
||||
sync_id,
|
||||
"sy_",
|
||||
3U
|
||||
) == 0
|
||||
);
|
||||
CHECK(strncmp(sync_id, "sy_", 3U) == 0);
|
||||
|
||||
CHECK(
|
||||
sync_id[3U + 14U] ==
|
||||
'4'
|
||||
);
|
||||
CHECK(sync_id[3U + 14U] == '4');
|
||||
|
||||
CHECK(
|
||||
trainlog_id_generate(
|
||||
"sxe",
|
||||
entry_id,
|
||||
sizeof(entry_id)
|
||||
) == TRAINLOG_STATUS_OK
|
||||
);
|
||||
CHECK(trainlog_id_generate("sxe", entry_id, sizeof(entry_id)) == TRAINLOG_STATUS_OK);
|
||||
CHECK(strncmp(entry_id, "sxe_", 4U) == 0);
|
||||
CHECK(strlen(entry_id) == TRAINLOG_GENERATED_ID_CAPACITY - 1U);
|
||||
CHECK(entry_id[4U + 14U] == '4');
|
||||
|
|
@ -137,19 +81,15 @@ static bool test_generated_ids(void)
|
|||
return true;
|
||||
}
|
||||
|
||||
static bool seed_exercise(TrainlogDatabase *database)
|
||||
{
|
||||
return trainlog_database_insert_exercise(
|
||||
database,
|
||||
"ex_test",
|
||||
"Presse à cuisses",
|
||||
"presse à cuisses",
|
||||
TRAINLOG_TRACKING_REPS
|
||||
) == TRAINLOG_STATUS_OK;
|
||||
static bool seed_exercise(TrainlogDatabase *database) {
|
||||
return trainlog_database_insert_exercise(database,
|
||||
"ex_test",
|
||||
"Presse à cuisses",
|
||||
"presse à cuisses",
|
||||
TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
static bool test_session_insert(void)
|
||||
{
|
||||
static bool test_session_insert(void) {
|
||||
TrainlogDatabase *database = NULL;
|
||||
TrainlogSetInput sets[3];
|
||||
TrainlogSessionExerciseInput exercise;
|
||||
|
|
@ -158,10 +98,7 @@ static bool test_session_insert(void)
|
|||
TrainlogPersistedExerciseDetail persisted_exercises[1];
|
||||
size_t count = 0U;
|
||||
|
||||
CHECK(
|
||||
trainlog_database_open(":memory:", &database) ==
|
||||
TRAINLOG_STATUS_OK
|
||||
);
|
||||
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(seed_exercise(database));
|
||||
|
||||
(void)memset(sets, 0, sizeof(sets));
|
||||
|
|
@ -175,12 +112,7 @@ static bool test_session_insert(void)
|
|||
sets[2].weight_kg = 0.0;
|
||||
|
||||
(void)memset(&exercise, 0, sizeof(exercise));
|
||||
(void)snprintf(
|
||||
exercise.exercise_id,
|
||||
sizeof(exercise.exercise_id),
|
||||
"%s",
|
||||
"ex_test"
|
||||
);
|
||||
(void)snprintf(exercise.exercise_id, sizeof(exercise.exercise_id), "%s", "ex_test");
|
||||
exercise.load_mode = TRAINLOG_LOAD_EXTERNAL;
|
||||
exercise.rest_seconds = 60;
|
||||
exercise.target_sets = 3;
|
||||
|
|
@ -191,50 +123,20 @@ static bool test_session_insert(void)
|
|||
exercise.set_count = 3U;
|
||||
|
||||
(void)memset(&session, 0, sizeof(session));
|
||||
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", "se_test");
|
||||
(void)snprintf(
|
||||
session.session_id,
|
||||
sizeof(session.session_id),
|
||||
"%s",
|
||||
"se_test"
|
||||
);
|
||||
(void)snprintf(
|
||||
session.started_at,
|
||||
sizeof(session.started_at),
|
||||
"%s",
|
||||
"2026-09-05T18:00:00+02:00"
|
||||
);
|
||||
(void)snprintf(
|
||||
session.ended_at,
|
||||
sizeof(session.ended_at),
|
||||
"%s",
|
||||
"2026-09-05T19:00:00+02:00"
|
||||
);
|
||||
session.started_at, sizeof(session.started_at), "%s", "2026-09-05T18:00:00+02:00");
|
||||
(void)snprintf(session.ended_at, sizeof(session.ended_at), "%s", "2026-09-05T19:00:00+02:00");
|
||||
session.exercises = &exercise;
|
||||
session.exercise_count = 1U;
|
||||
|
||||
CHECK(
|
||||
trainlog_database_insert_session(database, &session) ==
|
||||
TRAINLOG_STATUS_OK
|
||||
);
|
||||
CHECK(
|
||||
trainlog_database_insert_session(database, &session) ==
|
||||
TRAINLOG_STATUS_CONFLICT
|
||||
);
|
||||
CHECK(
|
||||
trainlog_database_session_count(database, &count) ==
|
||||
TRAINLOG_STATUS_OK
|
||||
);
|
||||
CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_CONFLICT);
|
||||
CHECK(trainlog_database_session_count(database, &count) == TRAINLOG_STATUS_OK);
|
||||
CHECK(count == 1U);
|
||||
CHECK(
|
||||
trainlog_database_get_session_details(
|
||||
database,
|
||||
"se_test",
|
||||
&persisted_session,
|
||||
persisted_exercises,
|
||||
1U,
|
||||
&count
|
||||
) == TRAINLOG_STATUS_OK
|
||||
);
|
||||
CHECK(trainlog_database_get_session_details(
|
||||
database, "se_test", &persisted_session, persisted_exercises, 1U, &count) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(count == 1U);
|
||||
/* Regression: local occurrences are `sxe`, never synchronization runs. */
|
||||
CHECK(strncmp(persisted_exercises[0].entry_id, "sxe_", 4U) == 0);
|
||||
|
|
@ -247,67 +149,39 @@ static bool test_session_insert(void)
|
|||
|
||||
trainlog_database_free_session_details(persisted_exercises, count);
|
||||
|
||||
(void)snprintf(session.session_id, sizeof(session.session_id), "%s",
|
||||
"se_negative_weight");
|
||||
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", "se_negative_weight");
|
||||
sets[0].weight_kg = -0.5;
|
||||
CHECK(trainlog_database_insert_session(database, &session) ==
|
||||
TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||
(void)snprintf(session.session_id, sizeof(session.session_id), "%s",
|
||||
"se_nonfinite_weight");
|
||||
CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", "se_nonfinite_weight");
|
||||
sets[0].weight_kg = INFINITY;
|
||||
CHECK(trainlog_database_insert_session(database, &session) ==
|
||||
TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||
CHECK(trainlog_database_session_count(database, &count) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||
CHECK(trainlog_database_session_count(database, &count) == TRAINLOG_STATUS_OK);
|
||||
CHECK(count == 1U);
|
||||
|
||||
trainlog_database_close(database);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool test_body_weight_history(void)
|
||||
{
|
||||
static bool test_body_weight_history(void) {
|
||||
TrainlogDatabase *database = NULL;
|
||||
TrainlogBodyObservationInput observation;
|
||||
TrainlogWeightPoint points[4];
|
||||
size_t count = 0U;
|
||||
|
||||
CHECK(
|
||||
trainlog_database_open(":memory:", &database) ==
|
||||
TRAINLOG_STATUS_OK
|
||||
);
|
||||
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||
|
||||
(void)memset(&observation, 0, sizeof(observation));
|
||||
(void)snprintf(
|
||||
observation.observation_id,
|
||||
sizeof(observation.observation_id),
|
||||
"%s",
|
||||
"bo_test"
|
||||
);
|
||||
(void)snprintf(
|
||||
observation.observed_at,
|
||||
sizeof(observation.observed_at),
|
||||
"%s",
|
||||
"2026-09-05T07:00:00+02:00"
|
||||
);
|
||||
(void)snprintf(observation.observation_id, sizeof(observation.observation_id), "%s", "bo_test");
|
||||
(void)snprintf(observation.observed_at,
|
||||
sizeof(observation.observed_at),
|
||||
"%s",
|
||||
"2026-09-05T07:00:00+02:00");
|
||||
observation.has_body_weight = true;
|
||||
observation.body_weight_kg = 82.4;
|
||||
|
||||
CHECK(
|
||||
trainlog_database_insert_body_observation(
|
||||
database,
|
||||
&observation
|
||||
) == TRAINLOG_STATUS_OK
|
||||
);
|
||||
CHECK(trainlog_database_insert_body_observation(database, &observation) == TRAINLOG_STATUS_OK);
|
||||
|
||||
CHECK(
|
||||
trainlog_database_list_weight_points(
|
||||
database,
|
||||
points,
|
||||
4U,
|
||||
&count
|
||||
) == TRAINLOG_STATUS_OK
|
||||
);
|
||||
CHECK(trainlog_database_list_weight_points(database, points, 4U, &count) == TRAINLOG_STATUS_OK);
|
||||
CHECK(count == 1U);
|
||||
CHECK(points[0].body_weight_kg > 82.39);
|
||||
CHECK(points[0].body_weight_kg < 82.41);
|
||||
|
|
@ -316,22 +190,15 @@ static bool test_body_weight_history(void)
|
|||
return true;
|
||||
}
|
||||
|
||||
static bool test_transaction_rollback(void)
|
||||
{
|
||||
static bool test_transaction_rollback(void) {
|
||||
TrainlogDatabase *database = NULL;
|
||||
size_t count = 0U;
|
||||
|
||||
CHECK(
|
||||
trainlog_database_open(":memory:", &database) ==
|
||||
TRAINLOG_STATUS_OK
|
||||
);
|
||||
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_begin(database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(seed_exercise(database));
|
||||
CHECK(trainlog_database_rollback(database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(
|
||||
trainlog_database_exercise_count(database, &count) ==
|
||||
TRAINLOG_STATUS_OK
|
||||
);
|
||||
CHECK(trainlog_database_exercise_count(database, &count) == TRAINLOG_STATUS_OK);
|
||||
/* Schema v13 seeds the five frozen supplied machine identities. The
|
||||
* rolled-back custom row must not change that baseline. */
|
||||
CHECK(count == 5U);
|
||||
|
|
@ -340,8 +207,7 @@ static bool test_transaction_rollback(void)
|
|||
return true;
|
||||
}
|
||||
|
||||
static bool test_exercise_merge_aliases_and_conflicts(void)
|
||||
{
|
||||
static bool test_exercise_merge_aliases_and_conflicts(void) {
|
||||
TrainlogDatabase *database = NULL;
|
||||
TrainlogExerciseBodyZone zones[4];
|
||||
const char *source_secondary[] = {"shoulders"};
|
||||
|
|
@ -350,50 +216,53 @@ static bool test_exercise_merge_aliases_and_conflicts(void)
|
|||
TrainlogExerciseMergePreview preview;
|
||||
size_t count = 0U;
|
||||
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_insert_exercise(database, "ex_source", "Curl A",
|
||||
"curl a", TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_insert_exercise(database, "ex_target", "Curl B",
|
||||
"curl b", TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_insert_exercise(database, "ex_final", "Curl C",
|
||||
"curl c", TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_insert_exercise(database, "ex_duration", "Hold",
|
||||
"hold", TRAINLOG_TRACKING_DURATION) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_replace_exercise_body_zones(database, "ex_source",
|
||||
"arms", source_secondary, 1U) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_replace_exercise_body_zones(database, "ex_target",
|
||||
"arms", target_secondary, 1U) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_preview_exercise_merge(database, "ex_source",
|
||||
&preview) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_insert_exercise(
|
||||
database, "ex_source", "Curl A", "curl a", TRAINLOG_TRACKING_REPS) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_insert_exercise(
|
||||
database, "ex_target", "Curl B", "curl b", TRAINLOG_TRACKING_REPS) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_insert_exercise(
|
||||
database, "ex_final", "Curl C", "curl c", TRAINLOG_TRACKING_REPS) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_insert_exercise(
|
||||
database, "ex_duration", "Hold", "hold", TRAINLOG_TRACKING_DURATION) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_replace_exercise_body_zones(
|
||||
database, "ex_source", "arms", source_secondary, 1U) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_replace_exercise_body_zones(
|
||||
database, "ex_target", "arms", target_secondary, 1U) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_preview_exercise_merge(database, "ex_source", &preview) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(preview.occurrences == 0U && preview.performed_sets == 0U &&
|
||||
preview.continuous_activities == 0U && preview.max_results == 0U &&
|
||||
preview.associated_equipment == 0U && preview.body_zones == 2U);
|
||||
preview.continuous_activities == 0U && preview.max_results == 0U &&
|
||||
preview.associated_equipment == 0U && preview.body_zones == 2U);
|
||||
|
||||
CHECK(trainlog_database_merge_exercises(database, "ex_source", "ex_target") ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_resolve_exercise_id(database, "ex_source", canonical,
|
||||
sizeof(canonical)) == TRAINLOG_STATUS_OK);
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_resolve_exercise_id(
|
||||
database, "ex_source", canonical, sizeof(canonical)) == TRAINLOG_STATUS_OK);
|
||||
CHECK(strcmp(canonical, "ex_target") == 0);
|
||||
CHECK(trainlog_database_list_exercise_body_zones(database, "ex_target", zones,
|
||||
4U, &count) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_list_exercise_body_zones(database, "ex_target", zones, 4U, &count) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(count == 3U);
|
||||
CHECK(strcmp(zones[0].zone_id, "arms") == 0 &&
|
||||
zones[0].role == TRAINLOG_BODY_ZONE_PRIMARY);
|
||||
CHECK(strcmp(zones[0].zone_id, "arms") == 0 && zones[0].role == TRAINLOG_BODY_ZONE_PRIMARY);
|
||||
|
||||
/* A second merge must collapse every old source directly to the newest
|
||||
* canonical ID; resolution never depends on an unbounded alias walk. */
|
||||
CHECK(trainlog_database_merge_exercises(database, "ex_target", "ex_final") ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_resolve_exercise_id(database, "ex_source", canonical,
|
||||
sizeof(canonical)) == TRAINLOG_STATUS_OK);
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_resolve_exercise_id(
|
||||
database, "ex_source", canonical, sizeof(canonical)) == TRAINLOG_STATUS_OK);
|
||||
CHECK(strcmp(canonical, "ex_final") == 0);
|
||||
CHECK(trainlog_database_resolve_exercise_id(database, "ex_target", canonical,
|
||||
sizeof(canonical)) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_resolve_exercise_id(
|
||||
database, "ex_target", canonical, sizeof(canonical)) == TRAINLOG_STATUS_OK);
|
||||
CHECK(strcmp(canonical, "ex_final") == 0);
|
||||
|
||||
CHECK(trainlog_database_merge_exercises(database, "ex_final", "ex_duration") ==
|
||||
TRAINLOG_STATUS_CONFLICT);
|
||||
CHECK(trainlog_database_resolve_exercise_id(database, "ex_final", canonical,
|
||||
sizeof(canonical)) == TRAINLOG_STATUS_OK);
|
||||
TRAINLOG_STATUS_CONFLICT);
|
||||
CHECK(trainlog_database_resolve_exercise_id(
|
||||
database, "ex_final", canonical, sizeof(canonical)) == TRAINLOG_STATUS_OK);
|
||||
CHECK(strcmp(canonical, "ex_final") == 0);
|
||||
CHECK(trainlog_database_exercise_count(database, &count) == TRAINLOG_STATUS_OK);
|
||||
CHECK(count == 7U);
|
||||
|
|
@ -406,8 +275,7 @@ struct TestCase {
|
|||
bool (*function)(void);
|
||||
};
|
||||
|
||||
int main(void)
|
||||
{
|
||||
int main(void) {
|
||||
static const struct TestCase tests[] = {
|
||||
{"database_open_and_schema", test_database_open_and_schema},
|
||||
{"database_open_diagnostic", test_database_open_diagnostic},
|
||||
|
|
|
|||
|
|
@ -432,12 +432,12 @@ static int run_test(void) {
|
|||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK);
|
||||
CHECK(version == 28);
|
||||
CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION);
|
||||
trainlog_database_close(database);
|
||||
database = NULL;
|
||||
|
||||
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
|
||||
CHECK(scalar(raw, "PRAGMA user_version") == 28);
|
||||
CHECK(scalar(raw, "PRAGMA user_version") == TRAINLOG_DATABASE_SCHEMA_VERSION);
|
||||
CHECK(capture_projection(raw,
|
||||
BUSINESS_PROJECTIONS,
|
||||
sizeof(BUSINESS_PROJECTIONS) / sizeof(BUSINESS_PROJECTIONS[0]),
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ static int run_test(void) {
|
|||
|
||||
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK);
|
||||
CHECK(version == 28);
|
||||
CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION);
|
||||
trainlog_database_close(production);
|
||||
production = NULL;
|
||||
|
||||
|
|
|
|||
73
tui/tests/test_schema_v29_migration.c
Normal file
73
tui/tests/test_schema_v29_migration.c
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include <sqlite3.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "trainlog/database.h"
|
||||
|
||||
#define CHECK(value) \
|
||||
do { \
|
||||
if (!(value)) { \
|
||||
fprintf(stderr, "CHECK failed line %d: %s\n", __LINE__, #value); \
|
||||
goto cleanup; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static int scalar(sqlite3 *database, const char *sql) {
|
||||
sqlite3_stmt *statement = NULL;
|
||||
int value = -1;
|
||||
if (sqlite3_prepare_v2(database, sql, -1, &statement, NULL) == SQLITE_OK &&
|
||||
sqlite3_step(statement) == SQLITE_ROW) {
|
||||
value = sqlite3_column_int(statement, 0);
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
return value;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
char path[] = "/tmp/trainlog-schema-v29-XXXXXX";
|
||||
TrainlogDatabase *production = NULL;
|
||||
sqlite3 *raw = NULL;
|
||||
int descriptor = mkstemp(path);
|
||||
int result = EXIT_FAILURE;
|
||||
CHECK(descriptor >= 0 && close(descriptor) == 0);
|
||||
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
|
||||
trainlog_database_close(production);
|
||||
production = NULL;
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw,
|
||||
"DROP TABLE sleep_diary_events;DROP TABLE sleep_diary_revisions;"
|
||||
"DROP TABLE sleep_diary_entries;PRAGMA user_version=28;",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||
raw = NULL;
|
||||
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
|
||||
trainlog_database_close(production);
|
||||
production = NULL;
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(scalar(raw, "PRAGMA user_version") == 29);
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'sleep_diary_%'") == 5);
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_foreign_key_check") == 0);
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_integrity_check WHERE integrity_check='ok'") ==
|
||||
1);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||
raw = NULL;
|
||||
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
|
||||
result = EXIT_SUCCESS;
|
||||
cleanup:
|
||||
if (production != NULL) {
|
||||
trainlog_database_close(production);
|
||||
}
|
||||
if (raw != NULL) {
|
||||
(void)sqlite3_close(raw);
|
||||
}
|
||||
(void)unlink(path);
|
||||
if (result == EXIT_SUCCESS) {
|
||||
puts("PASS schema v28 to v29 migration and reopen");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -178,8 +178,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) {
|
|||
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strncmp(diagnostic,
|
||||
"migrate database to schema v28: SQLite rc=",
|
||||
strlen("migrate database to schema v28: SQLite rc=")) == 0);
|
||||
"migrate database to schema v29: SQLite rc=",
|
||||
strlen("migrate database to schema v29: SQLite rc=")) == 0);
|
||||
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
||||
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
||||
CHECK(strstr(diagnostic, "already exists") != NULL);
|
||||
|
|
@ -197,12 +197,12 @@ static bool test_newer_schema_has_application_diagnostic(void) {
|
|||
CHECK(fd >= 0);
|
||||
CHECK(close(fd) == 0);
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=29;", NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=30;", NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
||||
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strstr(diagnostic, "schema version 29 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "schema version 30 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
||||
CHECK(unlink(path) == 0);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ static bool test_v9_to_v10_failure_rolls_back(void) {
|
|||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
||||
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strstr(diagnostic, "migrate database to schema v28") != NULL);
|
||||
CHECK(strstr(diagnostic, "migrate database to schema v29") != NULL);
|
||||
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
||||
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
|
|
|
|||
125
tui/tests/test_sleep_diary.c
Normal file
125
tui/tests/test_sleep_diary.c
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "trainlog/database.h"
|
||||
#include "trainlog/id.h"
|
||||
#include "trainlog/sleep_diary.h"
|
||||
#include "trainlog/web_sleep.h"
|
||||
|
||||
#define CHECK(value) \
|
||||
do { \
|
||||
if (!(value)) { \
|
||||
fprintf(stderr, "CHECK failed line %d: %s\n", __LINE__, #value); \
|
||||
return 1; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static TrainlogSleepDiaryEntry sample(void) {
|
||||
TrainlogSleepDiaryEntry entry = {0};
|
||||
(void)snprintf(entry.night_start_date, sizeof(entry.night_start_date), "2026-10-24");
|
||||
(void)snprintf(entry.night_end_date, sizeof(entry.night_end_date), "2026-10-25");
|
||||
(void)snprintf(entry.created_at, sizeof(entry.created_at), "2026-10-25T08:10:00+01:00");
|
||||
(void)snprintf(entry.updated_at, sizeof(entry.updated_at), "%s", entry.created_at);
|
||||
entry.sleep_quality = TRAINLOG_SLEEP_QUALITY_B;
|
||||
entry.wake_quality = TRAINLOG_SLEEP_QUALITY_MOY;
|
||||
(void)snprintf(
|
||||
entry.treatment_and_notes, sizeof(entry.treatment_and_notes), "Synthetic fixture");
|
||||
entry.event_count = 4U;
|
||||
(void)trainlog_id_generate("sle", entry.events[0].event_id, sizeof(entry.events[0].event_id));
|
||||
entry.events[0].type = TRAINLOG_SLEEP_EVENT_BED_TIME;
|
||||
(void)snprintf(
|
||||
entry.events[0].start_at, sizeof(entry.events[0].start_at), "2026-10-24T23:30:00+02:00");
|
||||
(void)trainlog_id_generate("sle", entry.events[1].event_id, sizeof(entry.events[1].event_id));
|
||||
entry.events[1].type = TRAINLOG_SLEEP_EVENT_SLEEP;
|
||||
(void)snprintf(
|
||||
entry.events[1].start_at, sizeof(entry.events[1].start_at), "2026-10-25T00:30:00+02:00");
|
||||
(void)snprintf(
|
||||
entry.events[1].end_at, sizeof(entry.events[1].end_at), "2026-10-25T02:30:00+02:00");
|
||||
(void)trainlog_id_generate("sle", entry.events[2].event_id, sizeof(entry.events[2].event_id));
|
||||
entry.events[2].type = TRAINLOG_SLEEP_EVENT_LONG_AWAKE;
|
||||
(void)snprintf(
|
||||
entry.events[2].start_at, sizeof(entry.events[2].start_at), "2026-10-25T02:30:00+02:00");
|
||||
(void)snprintf(
|
||||
entry.events[2].end_at, sizeof(entry.events[2].end_at), "2026-10-25T02:30:00+01:00");
|
||||
(void)trainlog_id_generate("sle", entry.events[3].event_id, sizeof(entry.events[3].event_id));
|
||||
entry.events[3].type = TRAINLOG_SLEEP_EVENT_FINAL_GET_UP;
|
||||
(void)snprintf(
|
||||
entry.events[3].start_at, sizeof(entry.events[3].start_at), "2026-10-25T08:00:00+01:00");
|
||||
return entry;
|
||||
}
|
||||
|
||||
static TrainlogStatus count_visitor(void *context, const TrainlogSleepDiaryEntry *entry) {
|
||||
size_t *count = context;
|
||||
if (entry == NULL) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
++*count;
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
TrainlogDatabase *database = NULL;
|
||||
TrainlogSleepDiaryEntry entry = sample();
|
||||
TrainlogSleepDiaryEntry loaded;
|
||||
TrainlogSleepDiaryEntry stale;
|
||||
TrainlogSleepDiaryEntry second;
|
||||
char *json = NULL;
|
||||
size_t json_size = 0U;
|
||||
char first_revision[TRAINLOG_SLEEP_REVISION_ID_CAPACITY];
|
||||
char deleted_revision[TRAINLOG_SLEEP_REVISION_ID_CAPACITY];
|
||||
size_t count = 0U;
|
||||
|
||||
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_sleep_diary_validate(&entry));
|
||||
CHECK(trainlog_sleep_diary_create(database, &entry) == TRAINLOG_STATUS_OK);
|
||||
second = sample();
|
||||
(void)snprintf(second.night_start_date, sizeof(second.night_start_date), "2026-10-25");
|
||||
(void)snprintf(second.night_end_date, sizeof(second.night_end_date), "2026-10-26");
|
||||
(void)snprintf(
|
||||
second.events[0].start_at, sizeof(second.events[0].start_at), "2026-10-26T00:30:00+01:00");
|
||||
CHECK(trainlog_sleep_diary_create(database, &second) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_web_sleep_list_json(database, NULL, NULL, 7U, &json, &json_size) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(json_size > 0U && strstr(json, "\"average_bed_minute\":360") != NULL);
|
||||
free(json);
|
||||
json = NULL;
|
||||
CHECK(trainlog_sleep_diary_get(database, entry.entry_id, false, &loaded) == TRAINLOG_STATUS_OK);
|
||||
CHECK(loaded.event_count == 4U && loaded.sleep_quality == TRAINLOG_SLEEP_QUALITY_B);
|
||||
(void)snprintf(first_revision, sizeof(first_revision), "%s", loaded.revision_id);
|
||||
stale = loaded;
|
||||
loaded.day_form = TRAINLOG_SLEEP_QUALITY_TB;
|
||||
(void)snprintf(loaded.updated_at, sizeof(loaded.updated_at), "2026-10-25T19:00:00+01:00");
|
||||
CHECK(trainlog_sleep_diary_update(database, first_revision, &loaded) == TRAINLOG_STATUS_OK);
|
||||
stale.day_form = TRAINLOG_SLEEP_QUALITY_TM;
|
||||
(void)snprintf(stale.updated_at, sizeof(stale.updated_at), "2026-10-25T20:00:00+01:00");
|
||||
CHECK(trainlog_sleep_diary_update(database, first_revision, &stale) ==
|
||||
TRAINLOG_STATUS_CONFLICT);
|
||||
CHECK(trainlog_sleep_diary_list(
|
||||
database, "2026-10-24", "2026-10-24", 7U, count_visitor, &count) ==
|
||||
TRAINLOG_STATUS_OK &&
|
||||
count == 1U);
|
||||
CHECK(trainlog_sleep_diary_delete(database,
|
||||
entry.entry_id,
|
||||
loaded.revision_id,
|
||||
"2026-10-26T09:00:00+01:00",
|
||||
deleted_revision) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_sleep_diary_get(database, entry.entry_id, false, &loaded) ==
|
||||
TRAINLOG_STATUS_NOT_FOUND);
|
||||
CHECK(trainlog_sleep_diary_get(database, entry.entry_id, true, &loaded) == TRAINLOG_STATUS_OK &&
|
||||
loaded.deleted);
|
||||
CHECK(trainlog_sleep_diary_delete(database,
|
||||
entry.entry_id,
|
||||
first_revision,
|
||||
"2026-10-26T10:00:00+01:00",
|
||||
deleted_revision) != TRAINLOG_STATUS_OK);
|
||||
|
||||
/* Same local clock across the DST fallback is a positive absolute interval. */
|
||||
entry = sample();
|
||||
(void)snprintf(
|
||||
entry.events[2].end_at, sizeof(entry.events[2].end_at), "2026-10-25T02:15:00+02:00");
|
||||
CHECK(!trainlog_sleep_diary_validate(&entry));
|
||||
trainlog_database_close(database);
|
||||
puts("PASS sleep diary domain");
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -219,7 +219,7 @@ static bool import_archive_and_prepare(void) {
|
|||
char *second_entry;
|
||||
|
||||
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(scalar(database, "PRAGMA user_version") == 28);
|
||||
CHECK(scalar(database, "PRAGMA user_version") == TRAINLOG_DATABASE_SCHEMA_VERSION);
|
||||
CHECK(scalar(database,
|
||||
"SELECT COUNT(*) FROM pragma_table_info('program_deletions') WHERE "
|
||||
"name IN('generation_id','acknowledged_at')") == 2);
|
||||
|
|
|
|||
Loading…
Reference in a new issue