Add human durations and body metric graphs
This commit is contained in:
parent
afd6847947
commit
1e1aef86d1
13 changed files with 1689 additions and 263 deletions
27
CHANGELOG.md
27
CHANGELOG.md
|
|
@ -37,8 +37,27 @@ Added:
|
||||||
|
|
||||||
Planned next:
|
Planned next:
|
||||||
|
|
||||||
- human duration input such as `1:30`, `1m30`, `2m`, and `45s`;
|
- human duration input such as `1:30`, `1m30`, `2m`, and `45s` (implemented);
|
||||||
- automatic minute/second display formatting;
|
- automatic minute/second display formatting (implemented);
|
||||||
- history graphs for all body measurements;
|
- history graphs for all body measurements (implemented);
|
||||||
- left/right asymmetry summaries.
|
- left/right asymmetry summaries (implemented).
|
||||||
<!-- TRAINLOG_TUI_V02_CHANGELOG _END -->
|
<!-- TRAINLOG_TUI_V02_CHANGELOG _END -->
|
||||||
|
|
||||||
|
<!-- TRAINLOG_GLOBAL_BODY_GRAPH_CHANGELOG -->
|
||||||
|
### Current TUI checkpoint
|
||||||
|
|
||||||
|
Implemented:
|
||||||
|
- human duration input;
|
||||||
|
- human minute/second display;
|
||||||
|
- graphs for every persisted body metric;
|
||||||
|
- recent values for every body metric;
|
||||||
|
- left/right asymmetry summaries.
|
||||||
|
|
||||||
|
Next:
|
||||||
|
- normalized global body overlay graph;
|
||||||
|
- metric legend using both colors and symbols;
|
||||||
|
- percentage evolution summary;
|
||||||
|
- richer dashboard weight graph;
|
||||||
|
- dashboard previous-weight delta and min/max;
|
||||||
|
- compact body/asymmetry dashboard summary.
|
||||||
|
<!-- TRAINLOG_GLOBAL_BODY_GRAPH_CHANGELOG _END -->
|
||||||
|
|
|
||||||
85
docs/reviews/tui_duration_body_checkpoint.md
Normal file
85
docs/reviews/tui_duration_body_checkpoint.md
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
# Human Duration and Body Graph Checkpoint
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
```text
|
||||||
|
FIRST_USABLE_TUI=PASS
|
||||||
|
TUI_V0_2_POLISH=IMPLEMENTED
|
||||||
|
TUI_SESSION_DETAILS=IMPLEMENTED
|
||||||
|
TUI_DURATION_HUMAN_INPUT=IMPLEMENTED
|
||||||
|
TUI_BODY_METRIC_GRAPHS=IMPLEMENTED
|
||||||
|
GATE_2=IN_PROGRESS
|
||||||
|
TRAINLOG_FORMAT_V1=FROZEN
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implemented
|
||||||
|
|
||||||
|
### Human duration entry
|
||||||
|
|
||||||
|
Accepted examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
90
|
||||||
|
90s
|
||||||
|
1:30
|
||||||
|
1m30
|
||||||
|
1m30s
|
||||||
|
2m
|
||||||
|
45s
|
||||||
|
2:05
|
||||||
|
```
|
||||||
|
|
||||||
|
Persistence remains canonical seconds.
|
||||||
|
Display uses human minute/second formatting.
|
||||||
|
|
||||||
|
### Body tracking
|
||||||
|
|
||||||
|
`F4 Corps` now provides per-metric history for weight, neck, shoulders, chest,
|
||||||
|
waist, hips, left/right arm, left/right forearm, left/right thigh and
|
||||||
|
left/right calf.
|
||||||
|
|
||||||
|
Each metric has latest value, first value, change, graph and recent dated
|
||||||
|
values. Paired limbs expose same-observation asymmetry.
|
||||||
|
|
||||||
|
## Next visualization contract
|
||||||
|
|
||||||
|
### Global body graph
|
||||||
|
|
||||||
|
All available body series are overlaid after normalizing each metric's first
|
||||||
|
real value to 100. The global graph is display-only.
|
||||||
|
|
||||||
|
### Dashboard graph v2
|
||||||
|
|
||||||
|
The home screen remains compact but gains:
|
||||||
|
- current weight;
|
||||||
|
- first-to-current delta;
|
||||||
|
- previous-to-current delta;
|
||||||
|
- min/max;
|
||||||
|
- richer recent weight graph;
|
||||||
|
- latest waist;
|
||||||
|
- compact asymmetry status.
|
||||||
|
|
||||||
|
## Validation before push
|
||||||
|
|
||||||
|
```bash
|
||||||
|
meson compile -C build
|
||||||
|
meson test -C build --print-errorlogs
|
||||||
|
python tools/validate_json.py
|
||||||
|
python tools/validate_import_contract.py
|
||||||
|
git diff --check
|
||||||
|
git diff --cached --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Sanitizer validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
meson compile -C build-asan
|
||||||
|
meson test -C build-asan --print-errorlogs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
```text
|
||||||
|
TUI_GLOBAL_BODY_OVERLAY
|
||||||
|
DASHBOARD_GRAPH_V2
|
||||||
|
```
|
||||||
18
docs/reviews/tui_duration_body_graphs.md
Normal file
18
docs/reviews/tui_duration_body_graphs.md
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Human Durations and Body Metric Graphs
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
```text
|
||||||
|
TUI_DURATION_HUMAN_INPUT=IMPLEMENTED
|
||||||
|
TUI_BODY_METRIC_GRAPHS=IMPLEMENTED
|
||||||
|
TRAINLOG_FORMAT_V1=FROZEN
|
||||||
|
```
|
||||||
|
|
||||||
|
Durations accept `90`, `90s`, `1:30`, `1m30`, `1m30s`, `2m`, `45s`
|
||||||
|
and `2:05`, while persistence remains integer seconds.
|
||||||
|
|
||||||
|
F4 now browses all frozen body measurements with left/right navigation,
|
||||||
|
latest/first/change summaries, a graph, recent values, and same-observation
|
||||||
|
left/right asymmetry for arms, forearms, thighs, and calves.
|
||||||
|
|
||||||
|
No database migration and no JSON v1 change are required.
|
||||||
|
|
@ -51,8 +51,8 @@ Current state:
|
||||||
FIRST_USABLE_TUI=PASS
|
FIRST_USABLE_TUI=PASS
|
||||||
TUI_V0_2_POLISH=IMPLEMENTED
|
TUI_V0_2_POLISH=IMPLEMENTED
|
||||||
TUI_SESSION_DETAILS=IMPLEMENTED
|
TUI_SESSION_DETAILS=IMPLEMENTED
|
||||||
TUI_DURATION_HUMAN_INPUT=NEXT
|
TUI_DURATION_HUMAN_INPUT=IMPLEMENTED
|
||||||
TUI_BODY_METRIC_GRAPHS=NEXT
|
TUI_BODY_METRIC_GRAPHS=IMPLEMENTED
|
||||||
GATE_2=IN_PROGRESS
|
GATE_2=IN_PROGRESS
|
||||||
TRAINLOG_FORMAT_V1=FROZEN
|
TRAINLOG_FORMAT_V1=FROZEN
|
||||||
```
|
```
|
||||||
|
|
@ -83,3 +83,34 @@ Next implementation slice:
|
||||||
|
|
||||||
No incompatible change to frozen Trainlog JSON v1 is required.
|
No incompatible change to frozen Trainlog JSON v1 is required.
|
||||||
<!-- TRAINLOG_TUI_V02_ROADMAP _END -->
|
<!-- TRAINLOG_TUI_V02_ROADMAP _END -->
|
||||||
|
|
||||||
|
<!-- TRAINLOG_GLOBAL_BODY_GRAPH_ROADMAP -->
|
||||||
|
## Next TUI visualization slice
|
||||||
|
|
||||||
|
Canonical state after the current checkpoint:
|
||||||
|
|
||||||
|
```text
|
||||||
|
FIRST_USABLE_TUI=PASS
|
||||||
|
TUI_V0_2_POLISH=IMPLEMENTED
|
||||||
|
TUI_SESSION_DETAILS=IMPLEMENTED
|
||||||
|
TUI_DURATION_HUMAN_INPUT=IMPLEMENTED
|
||||||
|
TUI_BODY_METRIC_GRAPHS=IMPLEMENTED
|
||||||
|
TUI_GLOBAL_BODY_OVERLAY=NEXT
|
||||||
|
DASHBOARD_GRAPH_V2=NEXT
|
||||||
|
GATE_2=IN_PROGRESS
|
||||||
|
TRAINLOG_FORMAT_V1=FROZEN
|
||||||
|
```
|
||||||
|
|
||||||
|
Next deliverables:
|
||||||
|
1. normalized global body graph in `F4`;
|
||||||
|
2. color + symbol identity for every overlaid metric;
|
||||||
|
3. global percent-change summary;
|
||||||
|
4. richer home weight graph;
|
||||||
|
5. previous-measurement delta on dashboard;
|
||||||
|
6. dashboard min/max weight;
|
||||||
|
7. latest waist summary when available;
|
||||||
|
8. compact asymmetry warning when relevant.
|
||||||
|
|
||||||
|
No database schema migration is expected.
|
||||||
|
No Trainlog JSON v1 change is expected.
|
||||||
|
<!-- TRAINLOG_GLOBAL_BODY_GRAPH_ROADMAP _END -->
|
||||||
|
|
|
||||||
73
docs/tui.md
73
docs/tui.md
|
|
@ -267,7 +267,7 @@ Inside one workout, left/right or up/down changes the selected exercise.
|
||||||
Assistance remains direction-aware: more assistance kilograms mean more help,
|
Assistance remains direction-aware: more assistance kilograms mean more help,
|
||||||
not greater strength.
|
not greater strength.
|
||||||
|
|
||||||
## 18. Duration input and display — next implementation slice
|
## 18. Duration input and display — implemented
|
||||||
|
|
||||||
Persistent duration and rest units remain **seconds**.
|
Persistent duration and rest units remain **seconds**.
|
||||||
|
|
||||||
|
|
@ -313,7 +313,7 @@ The same parser and formatter are reused for:
|
||||||
|
|
||||||
Invalid malformed forms are rejected before persistence.
|
Invalid malformed forms are rejected before persistence.
|
||||||
|
|
||||||
## 19. Body screen and measurement graphs — next implementation slice
|
## 19. Body screen and measurement graphs — implemented
|
||||||
|
|
||||||
`F4 Corps` becomes a full history and visualization screen.
|
`F4 Corps` becomes a full history and visualization screen.
|
||||||
|
|
||||||
|
|
@ -388,3 +388,72 @@ Immediate order:
|
||||||
7. Android recorder and JSON import workflow
|
7. Android recorder and JSON import workflow
|
||||||
```
|
```
|
||||||
<!-- TRAINLOG_TUI_V02_CURRENT_AND_NEXT _END -->
|
<!-- TRAINLOG_TUI_V02_CURRENT_AND_NEXT _END -->
|
||||||
|
|
||||||
|
<!-- TRAINLOG_GLOBAL_BODY_GRAPH_NEXT -->
|
||||||
|
## 21. Global body evolution graph — next implementation slice
|
||||||
|
|
||||||
|
`F4 Corps` keeps its current per-metric graph and gains a global normalized
|
||||||
|
overlay view.
|
||||||
|
|
||||||
|
The global graph must not overlay raw kilograms and centimeters directly.
|
||||||
|
|
||||||
|
Each metric is normalized to its own first real observation:
|
||||||
|
|
||||||
|
```text
|
||||||
|
first recorded value = 100
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
waist 100 -> 96 = -4 %
|
||||||
|
right arm 100 -> 103 = +3 %
|
||||||
|
weight 100 -> 98 = -2 %
|
||||||
|
```
|
||||||
|
|
||||||
|
This makes unlike units visually comparable without changing persisted data.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- no missing observation becomes zero;
|
||||||
|
- each metric begins only at its first real value;
|
||||||
|
- original dates remain ordered;
|
||||||
|
- every series has both a color and a distinct text/symbol identity;
|
||||||
|
- left/right limb metrics remain separate;
|
||||||
|
- normalization is display-only;
|
||||||
|
- canonical SQLite values remain untouched.
|
||||||
|
|
||||||
|
The global view is toggled from `F4 Corps` with `g`.
|
||||||
|
Per-metric navigation remains left/right.
|
||||||
|
|
||||||
|
The global view also shows a compact percentage summary from first to latest
|
||||||
|
recorded value for each available metric.
|
||||||
|
|
||||||
|
## 22. Dashboard graph v2 — next implementation slice
|
||||||
|
|
||||||
|
The dashboard body-weight graph becomes a richer summary.
|
||||||
|
|
||||||
|
It will show:
|
||||||
|
- current body weight;
|
||||||
|
- change from first recorded weight;
|
||||||
|
- change from previous recorded weight;
|
||||||
|
- minimum recorded weight;
|
||||||
|
- maximum recorded weight;
|
||||||
|
- recent weight graph;
|
||||||
|
- latest waist measurement when available;
|
||||||
|
- latest left/right asymmetry alert when meaningful.
|
||||||
|
|
||||||
|
The dashboard remains intentionally compact.
|
||||||
|
The complete multi-metric overlay belongs to `F4 Corps`.
|
||||||
|
|
||||||
|
## 23. Immediate TUI implementation order
|
||||||
|
|
||||||
|
```text
|
||||||
|
TUI_DURATION_HUMAN_INPUT=IMPLEMENTED
|
||||||
|
TUI_BODY_METRIC_GRAPHS=IMPLEMENTED
|
||||||
|
TUI_GLOBAL_BODY_OVERLAY=NEXT
|
||||||
|
DASHBOARD_GRAPH_V2=NEXT
|
||||||
|
EXERCISE_PERFORMANCE_GRAPHS=AFTER
|
||||||
|
```
|
||||||
|
|
||||||
|
Android remains deferred until these TUI daily-use views are satisfactory.
|
||||||
|
<!-- TRAINLOG_GLOBAL_BODY_GRAPH_NEXT _END -->
|
||||||
|
|
|
||||||
|
|
@ -124,4 +124,52 @@ TrainlogStatus trainlog_database_get_session_details(
|
||||||
size_t *output_exercise_count
|
size_t *output_exercise_count
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
/* TRAINLOG_BODY_METRIC_HISTORY_API */
|
||||||
|
|
||||||
|
typedef enum TrainlogBodyMetric {
|
||||||
|
TRAINLOG_BODY_METRIC_WEIGHT = 0,
|
||||||
|
TRAINLOG_BODY_METRIC_NECK,
|
||||||
|
TRAINLOG_BODY_METRIC_SHOULDERS,
|
||||||
|
TRAINLOG_BODY_METRIC_CHEST,
|
||||||
|
TRAINLOG_BODY_METRIC_WAIST,
|
||||||
|
TRAINLOG_BODY_METRIC_HIPS,
|
||||||
|
TRAINLOG_BODY_METRIC_LEFT_ARM,
|
||||||
|
TRAINLOG_BODY_METRIC_RIGHT_ARM,
|
||||||
|
TRAINLOG_BODY_METRIC_LEFT_FOREARM,
|
||||||
|
TRAINLOG_BODY_METRIC_RIGHT_FOREARM,
|
||||||
|
TRAINLOG_BODY_METRIC_LEFT_THIGH,
|
||||||
|
TRAINLOG_BODY_METRIC_RIGHT_THIGH,
|
||||||
|
TRAINLOG_BODY_METRIC_LEFT_CALF,
|
||||||
|
TRAINLOG_BODY_METRIC_RIGHT_CALF,
|
||||||
|
TRAINLOG_BODY_METRIC_COUNT
|
||||||
|
} TrainlogBodyMetric;
|
||||||
|
|
||||||
|
typedef struct TrainlogBodyMetricPoint {
|
||||||
|
char observed_at[TRAINLOG_TIMESTAMP_MAX + 1U];
|
||||||
|
double value;
|
||||||
|
} TrainlogBodyMetricPoint;
|
||||||
|
|
||||||
|
typedef struct TrainlogBodyPairPoint {
|
||||||
|
bool found;
|
||||||
|
char observed_at[TRAINLOG_TIMESTAMP_MAX + 1U];
|
||||||
|
double left_value;
|
||||||
|
double right_value;
|
||||||
|
} TrainlogBodyPairPoint;
|
||||||
|
|
||||||
|
TrainlogStatus trainlog_database_list_body_metric_points(
|
||||||
|
TrainlogDatabase *database,
|
||||||
|
TrainlogBodyMetric metric,
|
||||||
|
TrainlogBodyMetricPoint *output,
|
||||||
|
size_t capacity,
|
||||||
|
size_t *output_count
|
||||||
|
);
|
||||||
|
|
||||||
|
TrainlogStatus trainlog_database_latest_body_pair(
|
||||||
|
TrainlogDatabase *database,
|
||||||
|
TrainlogBodyMetric left_metric,
|
||||||
|
TrainlogBodyMetric right_metric,
|
||||||
|
TrainlogBodyPairPoint *output
|
||||||
|
);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
24
tui/include/trainlog/duration.h
Normal file
24
tui/include/trainlog/duration.h
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
#ifndef TRAINLOG_DURATION_H
|
||||||
|
#define TRAINLOG_DURATION_H
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file duration.h
|
||||||
|
* @brief Human duration parsing and formatting for Trainlog.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
#include "trainlog/status.h"
|
||||||
|
|
||||||
|
TrainlogStatus trainlog_duration_parse(
|
||||||
|
const char *text,
|
||||||
|
int *output_seconds
|
||||||
|
);
|
||||||
|
|
||||||
|
TrainlogStatus trainlog_duration_format(
|
||||||
|
int seconds,
|
||||||
|
char *output,
|
||||||
|
size_t output_size
|
||||||
|
);
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
@ -27,6 +27,7 @@ strict_c_args = [
|
||||||
trainlog_core_sources = files(
|
trainlog_core_sources = files(
|
||||||
'src/catalog.c',
|
'src/catalog.c',
|
||||||
'src/database.c',
|
'src/database.c',
|
||||||
|
'src/duration.c',
|
||||||
'src/id.c',
|
'src/id.c',
|
||||||
'src/timeutil.c',
|
'src/timeutil.c',
|
||||||
)
|
)
|
||||||
|
|
@ -107,3 +108,20 @@ test(
|
||||||
'session_detail',
|
'session_detail',
|
||||||
test_session_detail,
|
test_session_detail,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
test_duration = executable(
|
||||||
|
'test_duration',
|
||||||
|
'tests/test_duration.c',
|
||||||
|
dependencies: trainlog_core_dep,
|
||||||
|
c_args: strict_c_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
test_body_metrics = executable(
|
||||||
|
'test_body_metrics',
|
||||||
|
'tests/test_body_metrics.c',
|
||||||
|
dependencies: trainlog_core_dep,
|
||||||
|
c_args: strict_c_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
test('duration', test_duration)
|
||||||
|
test('body_metrics', test_body_metrics)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include "trainlog/database.h"
|
#include "trainlog/database.h"
|
||||||
|
#include "trainlog/duration.h"
|
||||||
|
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
|
|
@ -1351,20 +1352,31 @@ static TrainlogStatus detail_fill_sets(
|
||||||
} else if (has_duration != 0) {
|
} else if (has_duration != 0) {
|
||||||
int duration = sqlite3_column_int(statement, 1);
|
int duration = sqlite3_column_int(statement, 1);
|
||||||
|
|
||||||
|
char duration_text[64];
|
||||||
|
|
||||||
|
if (trainlog_duration_format(
|
||||||
|
duration,
|
||||||
|
duration_text,
|
||||||
|
sizeof(duration_text)
|
||||||
|
) != TRAINLOG_STATUS_OK) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
if (has_weight != 0) {
|
if (has_weight != 0) {
|
||||||
written = snprintf(
|
written = snprintf(
|
||||||
fragment,
|
fragment,
|
||||||
sizeof(fragment),
|
sizeof(fragment),
|
||||||
"%ds@%.1f",
|
"%s@%.1f",
|
||||||
duration,
|
duration_text,
|
||||||
sqlite3_column_double(statement, 2)
|
sqlite3_column_double(statement, 2)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
written = snprintf(
|
written = snprintf(
|
||||||
fragment,
|
fragment,
|
||||||
sizeof(fragment),
|
sizeof(fragment),
|
||||||
"%ds",
|
"%s",
|
||||||
duration
|
duration_text
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1661,3 +1673,240 @@ TrainlogStatus trainlog_database_get_session_details(
|
||||||
|
|
||||||
return TRAINLOG_STATUS_OK;
|
return TRAINLOG_STATUS_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* TRAINLOG_BODY_METRIC_HISTORY_IMPLEMENTATION */
|
||||||
|
|
||||||
|
static const char *body_metric_column(TrainlogBodyMetric metric)
|
||||||
|
{
|
||||||
|
switch (metric) {
|
||||||
|
case TRAINLOG_BODY_METRIC_WEIGHT:
|
||||||
|
return "body_weight_kg";
|
||||||
|
case TRAINLOG_BODY_METRIC_NECK:
|
||||||
|
return "neck_cm";
|
||||||
|
case TRAINLOG_BODY_METRIC_SHOULDERS:
|
||||||
|
return "shoulders_cm";
|
||||||
|
case TRAINLOG_BODY_METRIC_CHEST:
|
||||||
|
return "chest_cm";
|
||||||
|
case TRAINLOG_BODY_METRIC_WAIST:
|
||||||
|
return "waist_cm";
|
||||||
|
case TRAINLOG_BODY_METRIC_HIPS:
|
||||||
|
return "hips_cm";
|
||||||
|
case TRAINLOG_BODY_METRIC_LEFT_ARM:
|
||||||
|
return "left_arm_cm";
|
||||||
|
case TRAINLOG_BODY_METRIC_RIGHT_ARM:
|
||||||
|
return "right_arm_cm";
|
||||||
|
case TRAINLOG_BODY_METRIC_LEFT_FOREARM:
|
||||||
|
return "left_forearm_cm";
|
||||||
|
case TRAINLOG_BODY_METRIC_RIGHT_FOREARM:
|
||||||
|
return "right_forearm_cm";
|
||||||
|
case TRAINLOG_BODY_METRIC_LEFT_THIGH:
|
||||||
|
return "left_thigh_cm";
|
||||||
|
case TRAINLOG_BODY_METRIC_RIGHT_THIGH:
|
||||||
|
return "right_thigh_cm";
|
||||||
|
case TRAINLOG_BODY_METRIC_LEFT_CALF:
|
||||||
|
return "left_calf_cm";
|
||||||
|
case TRAINLOG_BODY_METRIC_RIGHT_CALF:
|
||||||
|
return "right_calf_cm";
|
||||||
|
case TRAINLOG_BODY_METRIC_COUNT:
|
||||||
|
default:
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TrainlogStatus trainlog_database_list_body_metric_points(
|
||||||
|
TrainlogDatabase *database,
|
||||||
|
TrainlogBodyMetric metric,
|
||||||
|
TrainlogBodyMetricPoint *output,
|
||||||
|
size_t capacity,
|
||||||
|
size_t *output_count
|
||||||
|
)
|
||||||
|
{
|
||||||
|
const char *column;
|
||||||
|
char sql[256];
|
||||||
|
sqlite3_stmt *statement = NULL;
|
||||||
|
size_t count = 0U;
|
||||||
|
int written;
|
||||||
|
int rc;
|
||||||
|
|
||||||
|
if (database == NULL ||
|
||||||
|
database->connection == NULL ||
|
||||||
|
output_count == NULL ||
|
||||||
|
(capacity > 0U && output == NULL)) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
column = body_metric_column(metric);
|
||||||
|
if (column == NULL) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
written = snprintf(
|
||||||
|
sql,
|
||||||
|
sizeof(sql),
|
||||||
|
"SELECT observed_at, %s "
|
||||||
|
"FROM body_observations "
|
||||||
|
"WHERE %s IS NOT NULL "
|
||||||
|
"ORDER BY observed_at ASC, id ASC;",
|
||||||
|
column,
|
||||||
|
column
|
||||||
|
);
|
||||||
|
|
||||||
|
if (written < 0 ||
|
||||||
|
(size_t)written >= sizeof(sql)) {
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
rc = sqlite3_prepare_v2(
|
||||||
|
database->connection,
|
||||||
|
sql,
|
||||||
|
-1,
|
||||||
|
&statement,
|
||||||
|
NULL
|
||||||
|
);
|
||||||
|
if (rc != SQLITE_OK) {
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||||
|
if (count < capacity) {
|
||||||
|
const unsigned char *observed =
|
||||||
|
sqlite3_column_text(statement, 0);
|
||||||
|
|
||||||
|
if (observed == NULL) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
(void)snprintf(
|
||||||
|
output[count].observed_at,
|
||||||
|
sizeof(output[count].observed_at),
|
||||||
|
"%s",
|
||||||
|
(const char *)observed
|
||||||
|
);
|
||||||
|
|
||||||
|
output[count].value =
|
||||||
|
sqlite3_column_double(statement, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
++count;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rc != SQLITE_DONE) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
*output_count =
|
||||||
|
count < capacity
|
||||||
|
? count
|
||||||
|
: capacity;
|
||||||
|
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
TrainlogStatus trainlog_database_latest_body_pair(
|
||||||
|
TrainlogDatabase *database,
|
||||||
|
TrainlogBodyMetric left_metric,
|
||||||
|
TrainlogBodyMetric right_metric,
|
||||||
|
TrainlogBodyPairPoint *output
|
||||||
|
)
|
||||||
|
{
|
||||||
|
const char *left_column;
|
||||||
|
const char *right_column;
|
||||||
|
char sql[384];
|
||||||
|
sqlite3_stmt *statement = NULL;
|
||||||
|
int written;
|
||||||
|
int rc;
|
||||||
|
|
||||||
|
if (database == NULL ||
|
||||||
|
database->connection == NULL ||
|
||||||
|
output == NULL) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
left_column = body_metric_column(left_metric);
|
||||||
|
right_column = body_metric_column(right_metric);
|
||||||
|
|
||||||
|
if (left_column == NULL ||
|
||||||
|
right_column == NULL) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
(void)memset(output, 0, sizeof(*output));
|
||||||
|
|
||||||
|
written = snprintf(
|
||||||
|
sql,
|
||||||
|
sizeof(sql),
|
||||||
|
"SELECT observed_at, %s, %s "
|
||||||
|
"FROM body_observations "
|
||||||
|
"WHERE %s IS NOT NULL AND %s IS NOT NULL "
|
||||||
|
"ORDER BY observed_at DESC, id DESC "
|
||||||
|
"LIMIT 1;",
|
||||||
|
left_column,
|
||||||
|
right_column,
|
||||||
|
left_column,
|
||||||
|
right_column
|
||||||
|
);
|
||||||
|
|
||||||
|
if (written < 0 ||
|
||||||
|
(size_t)written >= sizeof(sql)) {
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
rc = sqlite3_prepare_v2(
|
||||||
|
database->connection,
|
||||||
|
sql,
|
||||||
|
-1,
|
||||||
|
&statement,
|
||||||
|
NULL
|
||||||
|
);
|
||||||
|
if (rc != SQLITE_OK) {
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
rc = sqlite3_step(statement);
|
||||||
|
|
||||||
|
if (rc == SQLITE_DONE) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
output->found = false;
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rc != SQLITE_ROW) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const unsigned char *observed =
|
||||||
|
sqlite3_column_text(statement, 0);
|
||||||
|
|
||||||
|
if (observed == NULL) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
output->found = true;
|
||||||
|
|
||||||
|
(void)snprintf(
|
||||||
|
output->observed_at,
|
||||||
|
sizeof(output->observed_at),
|
||||||
|
"%s",
|
||||||
|
(const char *)observed
|
||||||
|
);
|
||||||
|
|
||||||
|
output->left_value =
|
||||||
|
sqlite3_column_double(statement, 1);
|
||||||
|
|
||||||
|
output->right_value =
|
||||||
|
sqlite3_column_double(statement, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sqlite3_finalize(statement) == SQLITE_OK
|
||||||
|
? TRAINLOG_STATUS_OK
|
||||||
|
: TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
|
|
||||||
270
tui/src/duration.c
Normal file
270
tui/src/duration.c
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
/**
|
||||||
|
* @file duration.c
|
||||||
|
* @brief Human duration parser/formatter implementation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "trainlog/duration.h"
|
||||||
|
|
||||||
|
#include <limits.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
static bool ascii_space(char value)
|
||||||
|
{
|
||||||
|
return value == ' ' ||
|
||||||
|
value == '\t' ||
|
||||||
|
value == '\n' ||
|
||||||
|
value == '\r' ||
|
||||||
|
value == '\f' ||
|
||||||
|
value == '\v';
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool ascii_digit(char value)
|
||||||
|
{
|
||||||
|
return value >= '0' && value <= '9';
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool parse_decimal_range(
|
||||||
|
const char *begin,
|
||||||
|
const char *end,
|
||||||
|
int *output
|
||||||
|
)
|
||||||
|
{
|
||||||
|
long long value = 0;
|
||||||
|
const char *cursor;
|
||||||
|
|
||||||
|
if (begin == NULL ||
|
||||||
|
end == NULL ||
|
||||||
|
output == NULL ||
|
||||||
|
begin >= end) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (cursor = begin; cursor < end; ++cursor) {
|
||||||
|
int digit;
|
||||||
|
|
||||||
|
if (!ascii_digit(*cursor)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
digit = *cursor - '0';
|
||||||
|
|
||||||
|
if (value > ((long long)INT_MAX - digit) / 10LL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = (value * 10LL) + digit;
|
||||||
|
}
|
||||||
|
|
||||||
|
*output = (int)value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool checked_minutes_seconds(
|
||||||
|
int minutes,
|
||||||
|
int seconds,
|
||||||
|
int *output
|
||||||
|
)
|
||||||
|
{
|
||||||
|
long long total;
|
||||||
|
|
||||||
|
if (minutes < 0 ||
|
||||||
|
seconds < 0 ||
|
||||||
|
seconds >= 60 ||
|
||||||
|
output == NULL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
total = ((long long)minutes * 60LL) + seconds;
|
||||||
|
|
||||||
|
if (total > (long long)INT_MAX) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
*output = (int)total;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
TrainlogStatus trainlog_duration_parse(
|
||||||
|
const char *text,
|
||||||
|
int *output_seconds
|
||||||
|
)
|
||||||
|
{
|
||||||
|
const char *begin;
|
||||||
|
const char *end;
|
||||||
|
const char *cursor;
|
||||||
|
const char *colon = NULL;
|
||||||
|
const char *minute_marker = NULL;
|
||||||
|
int value;
|
||||||
|
|
||||||
|
if (text == NULL || output_seconds == NULL) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
begin = text;
|
||||||
|
while (*begin != '\0' && ascii_space(*begin)) {
|
||||||
|
++begin;
|
||||||
|
}
|
||||||
|
|
||||||
|
end = text + strlen(text);
|
||||||
|
while (end > begin && ascii_space(end[-1])) {
|
||||||
|
--end;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (begin == end ||
|
||||||
|
(size_t)(end - begin) > 63U) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (cursor = begin; cursor < end; ++cursor) {
|
||||||
|
if (ascii_space(*cursor)) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (*cursor == ':') {
|
||||||
|
if (colon != NULL) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
colon = cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (*cursor == 'm' || *cursor == 'M') {
|
||||||
|
if (minute_marker != NULL) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
minute_marker = cursor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (colon != NULL) {
|
||||||
|
int minutes;
|
||||||
|
int seconds;
|
||||||
|
|
||||||
|
if (minute_marker != NULL ||
|
||||||
|
!parse_decimal_range(begin, colon, &minutes) ||
|
||||||
|
!parse_decimal_range(colon + 1, end, &seconds) ||
|
||||||
|
!checked_minutes_seconds(
|
||||||
|
minutes,
|
||||||
|
seconds,
|
||||||
|
output_seconds
|
||||||
|
)) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minute_marker != NULL) {
|
||||||
|
int minutes;
|
||||||
|
int seconds = 0;
|
||||||
|
const char *seconds_begin = minute_marker + 1;
|
||||||
|
const char *seconds_end = end;
|
||||||
|
|
||||||
|
if (!parse_decimal_range(
|
||||||
|
begin,
|
||||||
|
minute_marker,
|
||||||
|
&minutes
|
||||||
|
)) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seconds_begin < end &&
|
||||||
|
(end[-1] == 's' || end[-1] == 'S')) {
|
||||||
|
seconds_end = end - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seconds_begin < seconds_end) {
|
||||||
|
if (!parse_decimal_range(
|
||||||
|
seconds_begin,
|
||||||
|
seconds_end,
|
||||||
|
&seconds
|
||||||
|
)) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
} else if (seconds_begin < end) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checked_minutes_seconds(
|
||||||
|
minutes,
|
||||||
|
seconds,
|
||||||
|
output_seconds
|
||||||
|
)) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end[-1] == 's' || end[-1] == 'S') {
|
||||||
|
if (!parse_decimal_range(
|
||||||
|
begin,
|
||||||
|
end - 1,
|
||||||
|
&value
|
||||||
|
)) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
*output_seconds = value;
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parse_decimal_range(begin, end, &value)) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
*output_seconds = value;
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
TrainlogStatus trainlog_duration_format(
|
||||||
|
int seconds,
|
||||||
|
char *output,
|
||||||
|
size_t output_size
|
||||||
|
)
|
||||||
|
{
|
||||||
|
int minutes;
|
||||||
|
int remainder;
|
||||||
|
int written;
|
||||||
|
|
||||||
|
if (seconds < 0 ||
|
||||||
|
output == NULL ||
|
||||||
|
output_size == 0U) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
minutes = seconds / 60;
|
||||||
|
remainder = seconds % 60;
|
||||||
|
|
||||||
|
if (minutes == 0) {
|
||||||
|
written = snprintf(
|
||||||
|
output,
|
||||||
|
output_size,
|
||||||
|
"%d s",
|
||||||
|
remainder
|
||||||
|
);
|
||||||
|
} else if (remainder == 0) {
|
||||||
|
written = snprintf(
|
||||||
|
output,
|
||||||
|
output_size,
|
||||||
|
"%d min",
|
||||||
|
minutes
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
written = snprintf(
|
||||||
|
output,
|
||||||
|
output_size,
|
||||||
|
"%d min %d s",
|
||||||
|
minutes,
|
||||||
|
remainder
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (written < 0 ||
|
||||||
|
(size_t)written >= output_size) {
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
841
tui/src/tui.c
841
tui/src/tui.c
File diff suppressed because it is too large
Load diff
104
tui/tests/test_body_metrics.c
Normal file
104
tui/tests/test_body_metrics.c
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
/**
|
||||||
|
* @file test_body_metrics.c
|
||||||
|
* @brief Generic body-metric history tests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "trainlog/database.h"
|
||||||
|
|
||||||
|
#define CHECK(condition) \
|
||||||
|
do { \
|
||||||
|
if (!(condition)) { \
|
||||||
|
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", \
|
||||||
|
__FILE__, __LINE__, #condition); \
|
||||||
|
return false; \
|
||||||
|
} \
|
||||||
|
} while (0)
|
||||||
|
|
||||||
|
static bool test_body_metrics(void)
|
||||||
|
{
|
||||||
|
TrainlogDatabase *database = NULL;
|
||||||
|
TrainlogBodyObservationInput observation;
|
||||||
|
TrainlogBodyMetricPoint points[8];
|
||||||
|
TrainlogBodyPairPoint pair;
|
||||||
|
size_t count = 0U;
|
||||||
|
|
||||||
|
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_metrics"
|
||||||
|
);
|
||||||
|
|
||||||
|
(void)snprintf(
|
||||||
|
observation.observed_at,
|
||||||
|
sizeof(observation.observed_at),
|
||||||
|
"%s",
|
||||||
|
"2026-09-05T08:00:00+02:00"
|
||||||
|
);
|
||||||
|
|
||||||
|
observation.has_body_weight = true;
|
||||||
|
observation.body_weight_kg = 84.1;
|
||||||
|
observation.has_waist = true;
|
||||||
|
observation.waist_cm = 96.4;
|
||||||
|
observation.has_left_arm = true;
|
||||||
|
observation.left_arm_cm = 34.2;
|
||||||
|
observation.has_right_arm = true;
|
||||||
|
observation.right_arm_cm = 34.8;
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_database_insert_body_observation(
|
||||||
|
database,
|
||||||
|
&observation
|
||||||
|
) == TRAINLOG_STATUS_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_database_list_body_metric_points(
|
||||||
|
database,
|
||||||
|
TRAINLOG_BODY_METRIC_WAIST,
|
||||||
|
points,
|
||||||
|
8U,
|
||||||
|
&count
|
||||||
|
) == TRAINLOG_STATUS_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(count == 1U);
|
||||||
|
CHECK(points[0].value > 96.39);
|
||||||
|
CHECK(points[0].value < 96.41);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_database_latest_body_pair(
|
||||||
|
database,
|
||||||
|
TRAINLOG_BODY_METRIC_LEFT_ARM,
|
||||||
|
TRAINLOG_BODY_METRIC_RIGHT_ARM,
|
||||||
|
&pair
|
||||||
|
) == TRAINLOG_STATUS_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(pair.found);
|
||||||
|
CHECK(pair.left_value > 34.19);
|
||||||
|
CHECK(pair.left_value < 34.21);
|
||||||
|
CHECK(pair.right_value > 34.79);
|
||||||
|
CHECK(pair.right_value < 34.81);
|
||||||
|
|
||||||
|
trainlog_database_close(database);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
CHECK(test_body_metrics());
|
||||||
|
(void)printf("PASS body_metrics\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
112
tui/tests/test_duration.c
Normal file
112
tui/tests/test_duration.c
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
/**
|
||||||
|
* @file test_duration.c
|
||||||
|
* @brief Human duration parser/formatter tests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "trainlog/duration.h"
|
||||||
|
|
||||||
|
#define CHECK(condition) \
|
||||||
|
do { \
|
||||||
|
if (!(condition)) { \
|
||||||
|
(void)fprintf( \
|
||||||
|
stderr, \
|
||||||
|
"CHECK failed at %s:%d: %s\n", \
|
||||||
|
__FILE__, \
|
||||||
|
__LINE__, \
|
||||||
|
#condition \
|
||||||
|
); \
|
||||||
|
return false; \
|
||||||
|
} \
|
||||||
|
} while (0)
|
||||||
|
|
||||||
|
static bool expect_parse(const char *text, int expected)
|
||||||
|
{
|
||||||
|
int seconds = -1;
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_duration_parse(text, &seconds) ==
|
||||||
|
TRAINLOG_STATUS_OK
|
||||||
|
);
|
||||||
|
CHECK(seconds == expected);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool test_parsing(void)
|
||||||
|
{
|
||||||
|
int scratch = 0;
|
||||||
|
|
||||||
|
CHECK(expect_parse("90", 90));
|
||||||
|
CHECK(expect_parse("90s", 90));
|
||||||
|
CHECK(expect_parse("1:30", 90));
|
||||||
|
CHECK(expect_parse("1m30", 90));
|
||||||
|
CHECK(expect_parse("1m30s", 90));
|
||||||
|
CHECK(expect_parse("2m", 120));
|
||||||
|
CHECK(expect_parse("45s", 45));
|
||||||
|
CHECK(expect_parse("2:05", 125));
|
||||||
|
CHECK(expect_parse(" 2m ", 120));
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_duration_parse("1:60", &scratch) ==
|
||||||
|
TRAINLOG_STATUS_INVALID_ARGUMENT
|
||||||
|
);
|
||||||
|
CHECK(
|
||||||
|
trainlog_duration_parse("1m90", &scratch) ==
|
||||||
|
TRAINLOG_STATUS_INVALID_ARGUMENT
|
||||||
|
);
|
||||||
|
CHECK(
|
||||||
|
trainlog_duration_parse("1 m 30", &scratch) ==
|
||||||
|
TRAINLOG_STATUS_INVALID_ARGUMENT
|
||||||
|
);
|
||||||
|
CHECK(
|
||||||
|
trainlog_duration_parse("", &scratch) ==
|
||||||
|
TRAINLOG_STATUS_INVALID_ARGUMENT
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool test_formatting(void)
|
||||||
|
{
|
||||||
|
char buffer[64];
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_duration_format(45, buffer, sizeof(buffer)) ==
|
||||||
|
TRAINLOG_STATUS_OK
|
||||||
|
);
|
||||||
|
CHECK(strcmp(buffer, "45 s") == 0);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_duration_format(60, buffer, sizeof(buffer)) ==
|
||||||
|
TRAINLOG_STATUS_OK
|
||||||
|
);
|
||||||
|
CHECK(strcmp(buffer, "1 min") == 0);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_duration_format(90, buffer, sizeof(buffer)) ==
|
||||||
|
TRAINLOG_STATUS_OK
|
||||||
|
);
|
||||||
|
CHECK(strcmp(buffer, "1 min 30 s") == 0);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_duration_format(125, buffer, sizeof(buffer)) ==
|
||||||
|
TRAINLOG_STATUS_OK
|
||||||
|
);
|
||||||
|
CHECK(strcmp(buffer, "2 min 5 s") == 0);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
CHECK(test_parsing());
|
||||||
|
(void)printf("PASS duration_parsing\n");
|
||||||
|
|
||||||
|
CHECK(test_formatting());
|
||||||
|
(void)printf("PASS duration_formatting\n");
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue