mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:07:16 +01:00
Manual-workout phone sync P4: Dart model + validation
Dart port of the Python manual-workout model so the phone can build the same signed-on-ingest sync payload the PC form produces. - lib/models/manual_workout.dart: ManualWorkoutDraft (mirrors the Python dataclass), validateManualWorkout (ports validate_manual_workout: required fields, HH:MM end-after-start, min duration, RPE range, per-sport rules, reflection length), buildEntry/buildSyncPayload (byte-for-byte equivalent of the Python build_entry + kind/date), manualSyncRecordId, buildManualRecord. The MANUAL_WORKOUT_* constants are inlined here (a const-only file has no coverable lines and trips the 100%-coverage file-presence guard), replacing models/manual_workout_constants.dart. - test/models/manual_workout_test.dart: the cross-language wire fixture (an identical literal to test_manual_workout.py::TestSyncWireFormat — this catches key-name/format drift between the two serializers) plus full validation coverage. 29 tests; manual_workout.dart is 100% covered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH
This commit is contained in:
parent
7722260eeb
commit
6f2ec1fa86
@ -0,0 +1,335 @@
|
||||
/// Manual (off-app) workout model, validation, and sync-record building.
|
||||
///
|
||||
/// Mirrors the Python `screen_locker._manual_workout` module
|
||||
/// (`ManualWorkoutDraft`, `validate_manual_workout`, `build_entry` /
|
||||
/// `build_sync_payload`, `manual_sync_record_id`). The payload wire format is
|
||||
/// pinned by a cross-language fixture shared with the Python side
|
||||
/// (`test/models/manual_workout_test.dart` and its twin
|
||||
/// `test_manual_workout.py::TestSyncWireFormat`) — keep the two in lockstep.
|
||||
library;
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
|
||||
// ── Shared constants ─────────────────────────────────────────────────────────
|
||||
// Hand-maintained mirror of the Python source of truth in
|
||||
// `screen_locker/_constants.py` (the `MANUAL_WORKOUT_*` values); the two
|
||||
// languages can't share a file, so the cross-language payload fixture (see the
|
||||
// model test and its Python twin) is the tripwire that catches drift.
|
||||
|
||||
/// Max manual workouts allowed in any rolling 7-day window.
|
||||
const int kManualWorkoutBudgetPer7Days = 2;
|
||||
|
||||
/// Max manual workouts allowed in any rolling 30-day window.
|
||||
const int kManualWorkoutBudgetPer30Days = 5;
|
||||
|
||||
/// Minimum session length, in minutes, for a manual workout to count.
|
||||
const int kManualWorkoutMinDurationMinutes = 20;
|
||||
|
||||
/// Minimum characters for the "other sport" activity description.
|
||||
const int kManualWorkoutDescriptionMinChars = 40;
|
||||
|
||||
/// Minimum characters required in each reflection field.
|
||||
const int kManualWorkoutReflectionMinChars = 20;
|
||||
|
||||
/// Lowest valid RPE (rate of perceived exertion).
|
||||
const int kManualWorkoutRpeMin = 1;
|
||||
|
||||
/// Highest valid RPE (rate of perceived exertion).
|
||||
const int kManualWorkoutRpeMax = 10;
|
||||
|
||||
/// Record `kind` discriminator marking a manual-workout sync record.
|
||||
///
|
||||
/// A StrongLifts session payload has no `kind`; the ingesting PC routes on this
|
||||
/// value before any session logic, so a manual can never be stamped verified.
|
||||
const String kManualWorkoutSyncKind = 'manual_workout';
|
||||
|
||||
/// Internal sport code for table tennis (structured score fields).
|
||||
const String kSportTableTennis = 'table_tennis';
|
||||
|
||||
/// Internal sport code for any other sport (free-text activity fields).
|
||||
const String kSportOther = 'other';
|
||||
|
||||
/// The sport codes a manual workout may use.
|
||||
const List<String> kSportChoices = [kSportTableTennis, kSportOther];
|
||||
|
||||
/// Human labels for the sport codes, in menu order.
|
||||
const Map<String, String> kSportLabels = {
|
||||
kSportTableTennis: 'Table tennis',
|
||||
kSportOther: 'Other',
|
||||
};
|
||||
|
||||
/// User-supplied evidence fields for a manual (unverified) workout.
|
||||
///
|
||||
/// Fields shared by every sport come first; the sport-specific groups follow —
|
||||
/// only the group matching [sport] is validated/persisted.
|
||||
class ManualWorkoutDraft {
|
||||
/// Creates a manual-workout draft.
|
||||
const ManualWorkoutDraft({
|
||||
required this.sport,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
required this.locationName,
|
||||
required this.transportMethod,
|
||||
required this.cost,
|
||||
required this.rpe,
|
||||
required this.wentWell,
|
||||
required this.toImprove,
|
||||
required this.overallFeeling,
|
||||
this.reservationPhone = '',
|
||||
this.techniquesPracticed = '',
|
||||
this.warmUpMinutes = '',
|
||||
this.painOrInjury = 'none',
|
||||
this.matchesWon = 0,
|
||||
this.matchesLost = 0,
|
||||
this.setsWon = 0,
|
||||
this.setsLost = 0,
|
||||
this.racket = '',
|
||||
this.balls = '',
|
||||
this.activityTypeOther = '',
|
||||
this.activityDetails = '',
|
||||
this.equipment = '',
|
||||
});
|
||||
|
||||
/// Sport code (see [kSportChoices]).
|
||||
final String sport;
|
||||
|
||||
/// Start time as `HH:MM`.
|
||||
final String startTime;
|
||||
|
||||
/// End time as `HH:MM`.
|
||||
final String endTime;
|
||||
|
||||
/// Where the session happened.
|
||||
final String locationName;
|
||||
|
||||
/// How the user travelled there.
|
||||
final String transportMethod;
|
||||
|
||||
/// Session cost (free text, e.g. `40 PLN`).
|
||||
final String cost;
|
||||
|
||||
/// Rate of perceived exertion (1-10).
|
||||
final int rpe;
|
||||
|
||||
/// Reflection: what went well.
|
||||
final String wentWell;
|
||||
|
||||
/// Reflection: what to improve.
|
||||
final String toImprove;
|
||||
|
||||
/// Reflection: overall feeling.
|
||||
final String overallFeeling;
|
||||
|
||||
/// Optional reservation phone number.
|
||||
final String reservationPhone;
|
||||
|
||||
/// Optional techniques/focus areas practised.
|
||||
final String techniquesPracticed;
|
||||
|
||||
/// Optional warm-up duration (free text).
|
||||
final String warmUpMinutes;
|
||||
|
||||
/// Pain or injury notes (defaults to `none`).
|
||||
final String painOrInjury;
|
||||
|
||||
/// Table tennis: matches won.
|
||||
final int matchesWon;
|
||||
|
||||
/// Table tennis: matches lost.
|
||||
final int matchesLost;
|
||||
|
||||
/// Table tennis: sets won.
|
||||
final int setsWon;
|
||||
|
||||
/// Table tennis: sets lost.
|
||||
final int setsLost;
|
||||
|
||||
/// Table tennis: racket used.
|
||||
final String racket;
|
||||
|
||||
/// Table tennis: balls used.
|
||||
final String balls;
|
||||
|
||||
/// Other sport: what activity it was.
|
||||
final String activityTypeOther;
|
||||
|
||||
/// Other sport: description of what was done.
|
||||
final String activityDetails;
|
||||
|
||||
/// Other sport: equipment used.
|
||||
final String equipment;
|
||||
}
|
||||
|
||||
int? _parseHhmm(String value) {
|
||||
final match = RegExp(r'^(\d{1,2}):(\d{2})$').firstMatch(value.trim());
|
||||
if (match == null) return null;
|
||||
final hours = int.parse(match.group(1)!);
|
||||
final minutes = int.parse(match.group(2)!);
|
||||
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null;
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
/// Minutes between start and end, or null if unparsable / not end-after-start.
|
||||
double? durationMinutes(ManualWorkoutDraft draft) {
|
||||
final start = _parseHhmm(draft.startTime);
|
||||
final end = _parseHhmm(draft.endTime);
|
||||
if (start == null || end == null || end <= start) return null;
|
||||
return (end - start).toDouble();
|
||||
}
|
||||
|
||||
/// Returns an error message if [draft] is invalid, else null.
|
||||
///
|
||||
/// Ports `validate_manual_workout`: required fields, HH:MM with end after
|
||||
/// start, minimum duration, RPE range, per-sport rules, and reflection length.
|
||||
String? validateManualWorkout(ManualWorkoutDraft draft) {
|
||||
if (!kSportChoices.contains(draft.sport)) return 'Please choose a sport';
|
||||
|
||||
final required = <String, String>{
|
||||
'Start time': draft.startTime,
|
||||
'End time': draft.endTime,
|
||||
'Location name': draft.locationName,
|
||||
'Transport method': draft.transportMethod,
|
||||
'Cost': draft.cost,
|
||||
};
|
||||
for (final entry in required.entries) {
|
||||
if (entry.value.trim().isEmpty) return '${entry.key} is required';
|
||||
}
|
||||
|
||||
final duration = durationMinutes(draft);
|
||||
if (duration == null) {
|
||||
return 'Start/end time must be valid HH:MM, with end after start';
|
||||
}
|
||||
if (duration < kManualWorkoutMinDurationMinutes) {
|
||||
return 'Session must be at least $kManualWorkoutMinDurationMinutes '
|
||||
'minutes (currently ${duration.toStringAsFixed(0)})';
|
||||
}
|
||||
|
||||
if (draft.rpe < kManualWorkoutRpeMin || draft.rpe > kManualWorkoutRpeMax) {
|
||||
return 'RPE must be between $kManualWorkoutRpeMin '
|
||||
'and $kManualWorkoutRpeMax';
|
||||
}
|
||||
|
||||
final sportError = draft.sport == kSportTableTennis
|
||||
? _validateTableTennis(draft)
|
||||
: _validateOtherSport(draft);
|
||||
if (sportError != null) return sportError;
|
||||
|
||||
final reflections = <String, String>{
|
||||
'What went well': draft.wentWell,
|
||||
'What to improve': draft.toImprove,
|
||||
'Overall feeling': draft.overallFeeling,
|
||||
};
|
||||
for (final entry in reflections.entries) {
|
||||
final length = entry.value.trim().length;
|
||||
if (length < kManualWorkoutReflectionMinChars) {
|
||||
return '${entry.key} must be at least $kManualWorkoutReflectionMinChars '
|
||||
'characters (currently $length)';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateTableTennis(ManualWorkoutDraft draft) {
|
||||
final scores = <String, int>{
|
||||
'Matches won': draft.matchesWon,
|
||||
'Matches lost': draft.matchesLost,
|
||||
'Sets won': draft.setsWon,
|
||||
'Sets lost': draft.setsLost,
|
||||
};
|
||||
for (final entry in scores.entries) {
|
||||
if (entry.value < 0) return '${entry.key} cannot be negative';
|
||||
}
|
||||
if (draft.matchesWon + draft.matchesLost == 0) {
|
||||
return 'Enter at least one match played (won + lost)';
|
||||
}
|
||||
if (draft.racket.trim().isEmpty) return 'Racket is required';
|
||||
if (draft.balls.trim().isEmpty) return 'Balls are required';
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateOtherSport(ManualWorkoutDraft draft) {
|
||||
if (draft.activityTypeOther.trim().isEmpty) {
|
||||
return 'Activity type is required';
|
||||
}
|
||||
final length = draft.activityDetails.trim().length;
|
||||
if (length < kManualWorkoutDescriptionMinChars) {
|
||||
return 'What was done must be at least '
|
||||
'$kManualWorkoutDescriptionMinChars characters (currently $length)';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Builds the persisted `workout_data` dict for a validated draft.
|
||||
///
|
||||
/// Byte-for-byte equivalent of the Python `build_entry`.
|
||||
Map<String, Object?> buildEntry(ManualWorkoutDraft draft) {
|
||||
final duration = durationMinutes(draft);
|
||||
final activityLabel = draft.sport == kSportTableTennis
|
||||
? 'table tennis'
|
||||
: draft.activityTypeOther.trim();
|
||||
|
||||
final entry = <String, Object?>{
|
||||
'type': kManualWorkoutSyncKind,
|
||||
'source': '$activityLabel at ${draft.locationName.trim()}',
|
||||
'sport': draft.sport,
|
||||
'activity_type': activityLabel,
|
||||
'start_time': draft.startTime.trim(),
|
||||
'end_time': draft.endTime.trim(),
|
||||
'duration_minutes': duration != null ? duration.toStringAsFixed(1) : '',
|
||||
'location_name': draft.locationName.trim(),
|
||||
'transport_method': draft.transportMethod.trim(),
|
||||
'cost': draft.cost.trim(),
|
||||
'reservation_phone': draft.reservationPhone.trim(),
|
||||
'rpe': draft.rpe,
|
||||
'techniques_practiced': draft.techniquesPracticed.trim(),
|
||||
'warm_up_minutes': draft.warmUpMinutes.trim(),
|
||||
'pain_or_injury': draft.painOrInjury.trim(),
|
||||
'went_well': draft.wentWell.trim(),
|
||||
'to_improve': draft.toImprove.trim(),
|
||||
'overall_feeling': draft.overallFeeling.trim(),
|
||||
};
|
||||
if (draft.sport == kSportTableTennis) {
|
||||
entry.addAll(<String, Object?>{
|
||||
'matches_won': draft.matchesWon,
|
||||
'matches_lost': draft.matchesLost,
|
||||
'sets_won': draft.setsWon,
|
||||
'sets_lost': draft.setsLost,
|
||||
'racket': draft.racket.trim(),
|
||||
'balls': draft.balls.trim(),
|
||||
});
|
||||
} else {
|
||||
entry.addAll(<String, Object?>{
|
||||
'activity_details': draft.activityDetails.trim(),
|
||||
'equipment': draft.equipment.trim(),
|
||||
});
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/// Builds the cross-device sync payload: [buildEntry] + `kind` + `date`.
|
||||
Map<String, Object?> buildSyncPayload(ManualWorkoutDraft draft, String date) {
|
||||
return <String, Object?>{
|
||||
...buildEntry(draft),
|
||||
'kind': kManualWorkoutSyncKind,
|
||||
'date': date,
|
||||
};
|
||||
}
|
||||
|
||||
/// The stable crdt-sync record id for a manual workout.
|
||||
///
|
||||
/// Format: `manual:<date>T<HH:MM>`.
|
||||
String manualSyncRecordId(String date, String startTime) =>
|
||||
'manual:${date}T${startTime.trim()}';
|
||||
|
||||
/// Builds the crdt-sync [Record] for a manual workout, stamped with [hlc].
|
||||
Record buildManualRecord(
|
||||
ManualWorkoutDraft draft,
|
||||
String date, {
|
||||
required Hlc hlc,
|
||||
}) {
|
||||
return Record(
|
||||
id: manualSyncRecordId(date, draft.startTime),
|
||||
fields: {'payload': (buildSyncPayload(draft, date), hlc)},
|
||||
);
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
/// Shared constants for manual (off-app) workout logging.
|
||||
///
|
||||
/// These mirror the Python source of truth in the screen-locker package
|
||||
/// (`screen_locker/_constants.py`, the `MANUAL_WORKOUT_*` values). The two
|
||||
/// languages cannot share a constants file, so this is a hand-maintained
|
||||
/// mirror; the cross-language sync-payload fixture (see the manual_workout
|
||||
/// model test here and its Python twin,
|
||||
/// `test_manual_workout.py::TestSyncWireFormat`) is the tripwire that catches
|
||||
/// drift. Keep these values in sync with `_constants.py`.
|
||||
library;
|
||||
|
||||
/// Max manual workouts allowed in any rolling 7-day window.
|
||||
const int kManualWorkoutBudgetPer7Days = 2;
|
||||
|
||||
/// Max manual workouts allowed in any rolling 30-day window.
|
||||
const int kManualWorkoutBudgetPer30Days = 5;
|
||||
|
||||
/// Minimum session length, in minutes, for a manual workout to count.
|
||||
const int kManualWorkoutMinDurationMinutes = 20;
|
||||
|
||||
/// Minimum characters for the "other sport" activity description.
|
||||
const int kManualWorkoutDescriptionMinChars = 40;
|
||||
|
||||
/// Minimum characters required in each reflection field.
|
||||
const int kManualWorkoutReflectionMinChars = 20;
|
||||
|
||||
/// Lowest valid RPE (rate of perceived exertion).
|
||||
const int kManualWorkoutRpeMin = 1;
|
||||
|
||||
/// Highest valid RPE (rate of perceived exertion).
|
||||
const int kManualWorkoutRpeMax = 10;
|
||||
|
||||
/// Record `kind` discriminator marking a manual-workout sync record.
|
||||
///
|
||||
/// A StrongLifts session payload has no `kind`; the ingesting PC routes on this
|
||||
/// value before any session logic, so a manual can never be stamped as a
|
||||
/// verified session.
|
||||
const String kManualWorkoutSyncKind = 'manual_workout';
|
||||
@ -0,0 +1,270 @@
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:workout_app/models/manual_workout.dart';
|
||||
|
||||
// Builds a VALID table-tennis draft; override individual fields per test.
|
||||
ManualWorkoutDraft _tt({
|
||||
String sport = kSportTableTennis,
|
||||
String startTime = '18:00',
|
||||
String endTime = '19:30',
|
||||
String locationName = 'Solec',
|
||||
String transportMethod = 'bike',
|
||||
String cost = '40 PLN',
|
||||
int rpe = 6,
|
||||
String wentWell = 'Served consistently and moved feet well',
|
||||
String toImprove = 'Backhand topspin needs more consistency',
|
||||
String overallFeeling = 'Felt strong focused and in good rhythm',
|
||||
int matchesWon = 3,
|
||||
int matchesLost = 1,
|
||||
int setsWon = 7,
|
||||
int setsLost = 4,
|
||||
String racket = 'Butterfly',
|
||||
String balls = 'Nittaku 3-star',
|
||||
}) => ManualWorkoutDraft(
|
||||
sport: sport,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
locationName: locationName,
|
||||
transportMethod: transportMethod,
|
||||
cost: cost,
|
||||
rpe: rpe,
|
||||
wentWell: wentWell,
|
||||
toImprove: toImprove,
|
||||
overallFeeling: overallFeeling,
|
||||
matchesWon: matchesWon,
|
||||
matchesLost: matchesLost,
|
||||
setsWon: setsWon,
|
||||
setsLost: setsLost,
|
||||
racket: racket,
|
||||
balls: balls,
|
||||
);
|
||||
|
||||
// Builds a VALID "other sport" draft; override individual fields per test.
|
||||
ManualWorkoutDraft _other({
|
||||
String activityTypeOther = 'squash',
|
||||
String activityDetails =
|
||||
'Full-court squash drills and three practice games total',
|
||||
String equipment = 'racket, goggles',
|
||||
}) => ManualWorkoutDraft(
|
||||
sport: kSportOther,
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
locationName: 'Squash club',
|
||||
transportMethod: 'car',
|
||||
cost: '30 PLN',
|
||||
rpe: 5,
|
||||
wentWell: 'Moved well and kept long rallies going',
|
||||
toImprove: 'Need to volley earlier off the back wall',
|
||||
overallFeeling: 'Tired but satisfied with the effort',
|
||||
activityTypeOther: activityTypeOther,
|
||||
activityDetails: activityDetails,
|
||||
equipment: equipment,
|
||||
);
|
||||
|
||||
// The cross-language wire fixture — an IDENTICAL literal lives in the Python
|
||||
// suite (test_manual_workout.py::TestSyncWireFormat). Neither side's own
|
||||
// round-trip test catches a key-name/format drift from the other; this shared
|
||||
// literal on both sides does. Change one, change the other.
|
||||
const _wireDate = '2026-07-13';
|
||||
const _wirePayload = <String, Object?>{
|
||||
'type': 'manual_workout',
|
||||
'source': 'table tennis at Solec',
|
||||
'sport': 'table_tennis',
|
||||
'activity_type': 'table tennis',
|
||||
'start_time': '18:00',
|
||||
'end_time': '19:30',
|
||||
'duration_minutes': '90.0',
|
||||
'location_name': 'Solec',
|
||||
'transport_method': 'bike',
|
||||
'cost': '40 PLN',
|
||||
'reservation_phone': '600100200',
|
||||
'rpe': 6,
|
||||
'techniques_practiced': 'topspin serve',
|
||||
'warm_up_minutes': '10',
|
||||
'pain_or_injury': 'none',
|
||||
'went_well': 'Served consistently and moved feet well',
|
||||
'to_improve': 'Backhand topspin needs more consistency',
|
||||
'overall_feeling': 'Felt strong focused and in good rhythm',
|
||||
'matches_won': 3,
|
||||
'matches_lost': 1,
|
||||
'sets_won': 7,
|
||||
'sets_lost': 4,
|
||||
'racket': 'Butterfly',
|
||||
'balls': 'Nittaku 3-star',
|
||||
'kind': 'manual_workout',
|
||||
'date': '2026-07-13',
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('cross-language wire format', () {
|
||||
// Matches the _wire draft used to produce _wirePayload on the Python side.
|
||||
final wireDraft = _tt(
|
||||
// These optional fields aren't in _tt's parameter list; set via a fresh
|
||||
// draft below so the fixture is exact.
|
||||
);
|
||||
|
||||
test('buildSyncPayload matches the shared fixture', () {
|
||||
const draft = ManualWorkoutDraft(
|
||||
sport: kSportTableTennis,
|
||||
startTime: '18:00',
|
||||
endTime: '19:30',
|
||||
locationName: 'Solec',
|
||||
transportMethod: 'bike',
|
||||
cost: '40 PLN',
|
||||
rpe: 6,
|
||||
wentWell: 'Served consistently and moved feet well',
|
||||
toImprove: 'Backhand topspin needs more consistency',
|
||||
overallFeeling: 'Felt strong focused and in good rhythm',
|
||||
reservationPhone: '600100200',
|
||||
techniquesPracticed: 'topspin serve',
|
||||
warmUpMinutes: '10',
|
||||
matchesWon: 3,
|
||||
matchesLost: 1,
|
||||
setsWon: 7,
|
||||
setsLost: 4,
|
||||
racket: 'Butterfly',
|
||||
balls: 'Nittaku 3-star',
|
||||
);
|
||||
expect(buildSyncPayload(draft, _wireDate), _wirePayload);
|
||||
});
|
||||
|
||||
test('record id is stable and prefixed', () {
|
||||
expect(manualSyncRecordId(_wireDate, '18:00'), 'manual:2026-07-13T18:00');
|
||||
});
|
||||
|
||||
test('buildManualRecord wraps the payload under a manual id', () {
|
||||
final record = buildManualRecord(
|
||||
wireDraft,
|
||||
_wireDate,
|
||||
hlc: Hlc.newTick('phone'),
|
||||
);
|
||||
expect(record.id, 'manual:2026-07-13T18:00');
|
||||
expect(record.fields['payload']!.$1, buildSyncPayload(wireDraft, _wireDate));
|
||||
});
|
||||
});
|
||||
|
||||
group('validateManualWorkout', () {
|
||||
test('valid table tennis returns null', () {
|
||||
expect(validateManualWorkout(_tt()), isNull);
|
||||
});
|
||||
|
||||
test('valid other sport returns null', () {
|
||||
expect(validateManualWorkout(_other()), isNull);
|
||||
});
|
||||
|
||||
test('rejects an unknown sport', () {
|
||||
expect(validateManualWorkout(_tt(sport: 'badminton')), isNotNull);
|
||||
});
|
||||
|
||||
for (final field in ['start', 'end', 'location', 'transport', 'cost']) {
|
||||
test('rejects blank $field', () {
|
||||
final draft = switch (field) {
|
||||
'start' => _tt(startTime: ' '),
|
||||
'end' => _tt(endTime: ' '),
|
||||
'location' => _tt(locationName: ' '),
|
||||
'transport' => _tt(transportMethod: ' '),
|
||||
_ => _tt(cost: ' '),
|
||||
};
|
||||
expect(validateManualWorkout(draft), isNotNull);
|
||||
});
|
||||
}
|
||||
|
||||
test('rejects a bad time format', () {
|
||||
expect(validateManualWorkout(_tt(startTime: 'noon')), isNotNull);
|
||||
});
|
||||
|
||||
test('rejects end not after start', () {
|
||||
expect(
|
||||
validateManualWorkout(_tt(startTime: '14:00', endTime: '12:00')),
|
||||
isNotNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects an out-of-range hour', () {
|
||||
expect(validateManualWorkout(_tt(startTime: '25:00')), isNotNull);
|
||||
});
|
||||
|
||||
test('rejects a too-short session', () {
|
||||
expect(
|
||||
validateManualWorkout(_tt(startTime: '12:00', endTime: '12:05')),
|
||||
isNotNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects an out-of-range RPE', () {
|
||||
expect(validateManualWorkout(_tt(rpe: 0)), isNotNull);
|
||||
expect(validateManualWorkout(_tt(rpe: 11)), isNotNull);
|
||||
});
|
||||
|
||||
test('rejects a short reflection', () {
|
||||
expect(validateManualWorkout(_tt(wentWell: 'ok')), isNotNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('table tennis validation', () {
|
||||
test('rejects a negative score', () {
|
||||
expect(validateManualWorkout(_tt(matchesWon: -1)), isNotNull);
|
||||
});
|
||||
|
||||
test('rejects zero matches played', () {
|
||||
expect(
|
||||
validateManualWorkout(_tt(matchesWon: 0, matchesLost: 0)),
|
||||
isNotNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('requires a racket', () {
|
||||
expect(validateManualWorkout(_tt(racket: ' ')), isNotNull);
|
||||
});
|
||||
|
||||
test('requires balls', () {
|
||||
expect(validateManualWorkout(_tt(balls: ' ')), isNotNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('other sport validation', () {
|
||||
test('requires an activity type', () {
|
||||
expect(validateManualWorkout(_other(activityTypeOther: ' ')), isNotNull);
|
||||
});
|
||||
|
||||
test('requires a long-enough description', () {
|
||||
expect(validateManualWorkout(_other(activityDetails: 'short')), isNotNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('buildEntry', () {
|
||||
test('table tennis carries score fields and a table-tennis label', () {
|
||||
final entry = buildEntry(_tt());
|
||||
expect(entry['activity_type'], 'table tennis');
|
||||
expect(entry['matches_lost'], 1);
|
||||
expect(entry['source'], 'table tennis at Solec');
|
||||
});
|
||||
|
||||
test('other sport carries description/equipment and its own label', () {
|
||||
final entry = buildEntry(_other());
|
||||
expect(entry['activity_type'], 'squash');
|
||||
expect(entry['activity_details'], contains('squash'));
|
||||
expect(entry['equipment'], 'racket, goggles');
|
||||
});
|
||||
|
||||
test('duration is empty for an unparsable time', () {
|
||||
expect(buildEntry(_tt(startTime: 'bogus'))['duration_minutes'], '');
|
||||
});
|
||||
|
||||
test('strips whitespace from fields', () {
|
||||
final entry = buildEntry(_tt(locationName: ' Solec ', racket: ' pro '));
|
||||
expect(entry['location_name'], 'Solec');
|
||||
expect(entry['racket'], 'pro');
|
||||
});
|
||||
});
|
||||
|
||||
group('durationMinutes', () {
|
||||
test('returns minutes for a valid range', () {
|
||||
expect(durationMinutes(_tt(startTime: '12:00', endTime: '13:30')), 90.0);
|
||||
});
|
||||
|
||||
test('returns null for an unparsable time', () {
|
||||
expect(durationMinutes(_tt(startTime: 'noon')), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user