changes
All checks were successful
CI / ci (push) Successful in 19m38s

This commit is contained in:
2026-08-26 20:55:30 +02:00
parent a557c183e9
commit 23d052278a
523 changed files with 24448 additions and 2005 deletions

View File

@@ -0,0 +1,2 @@
#[path = "api_token/api_token_test.rs"]
mod api_token_test;

View File

@@ -0,0 +1,57 @@
use domain::api_token::{ApiToken, MintedApiToken, TokenDigest, TokenScope};
use domain::provider::ProviderName;
use domain::user::UserId;
fn a_token() -> ApiToken {
ApiToken::new(
UserId::generate(),
ProviderName::new("iphone-shortcuts").unwrap(),
TokenDigest::from_persistence("digest-of-the-secret".into()),
)
}
#[test]
fn a_fresh_token_has_never_been_used() {
let token = a_token();
assert!(token.last_used_at().is_none());
assert_eq!(token.scope(), TokenScope::WriteMetrics);
}
#[test]
fn a_token_remembers_when_it_was_last_used() {
let mut token = a_token();
token.mark_used();
assert!(token.last_used_at().is_some());
}
#[test]
fn the_name_a_token_carries_is_the_provider_its_writes_are_attributed_to() {
let token = a_token();
assert_eq!(token.name().value(), "iphone-shortcuts");
}
#[test]
fn every_scope_survives_a_round_trip_through_its_name() {
assert_eq!(
TokenScope::from_name(TokenScope::WriteMetrics.name()),
Some(TokenScope::WriteMetrics)
);
assert_eq!(TokenScope::from_name("read-everything"), None);
}
#[test]
fn a_minted_token_keeps_the_secret_out_of_its_debug_output() {
let minted = MintedApiToken::new(a_token(), "kmood_supersecretvalue".into());
let printed = format!("{minted:?}");
assert!(
!printed.contains("supersecret"),
"the secret leaked: {printed}"
);
assert_eq!(minted.secret(), "kmood_supersecretvalue");
}

View File

@@ -0,0 +1,14 @@
#[path = "correlation/spearman_test.rs"]
mod spearman_test;
#[path = "correlation/moon_phase_test.rs"]
mod moon_phase_test;
#[path = "correlation/strategy_test.rs"]
mod strategy_test;
#[path = "correlation/significance_test.rs"]
mod significance_test;
#[path = "correlation/adjustment_test.rs"]
mod adjustment_test;

View File

@@ -0,0 +1,180 @@
use domain::correlation::{Adjustment, PValue};
fn p_values(values: &[f64]) -> Vec<PValue> {
values
.iter()
.map(|value| PValue::new(*value).unwrap())
.collect()
}
#[test]
fn the_step_up_procedure_keeps_everything_below_the_largest_passing_rank() {
let adjustment = Adjustment::controlling_false_discovery_at(0.10);
let held = adjustment.holds_up(&p_values(&[0.001, 0.008, 0.039, 0.041, 0.42]));
assert_eq!(held, [true, true, true, true, false]);
}
#[test]
fn a_result_below_its_own_threshold_is_carried_by_a_stronger_one() {
let adjustment = Adjustment::controlling_false_discovery_at(0.10);
let held = adjustment.holds_up(&p_values(&[0.001, 0.079]));
assert_eq!(
held,
[true, true],
"0.079 exceeds rank 1's threshold but rank 2 passes, so both hold"
);
}
#[test]
fn order_of_the_set_does_not_change_who_holds_up() {
let adjustment = Adjustment::controlling_false_discovery_at(0.10);
let held = adjustment.holds_up(&p_values(&[0.42, 0.041, 0.001, 0.039, 0.008]));
assert_eq!(held, [false, true, true, true, true]);
}
#[test]
fn nothing_holds_up_when_nothing_is_small_enough() {
let adjustment = Adjustment::controlling_false_discovery_at(0.10);
let held = adjustment.holds_up(&p_values(&[0.4, 0.5, 0.6]));
assert_eq!(held, [false, false, false]);
}
#[test]
fn a_larger_set_makes_each_result_work_harder() {
let adjustment = Adjustment::controlling_false_discovery_at(0.10);
let alone = adjustment.holds_up(&p_values(&[0.04]));
let among_ten = adjustment.holds_up(&p_values(&[
0.04, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5,
]));
assert_eq!(alone, [true]);
assert!(!among_ten[0], "0.04 needs to beat 0.01 once ten are tested");
}
#[test]
fn the_threshold_is_whatever_it_is_configured_to_be() {
let strict = Adjustment::controlling_false_discovery_at(0.01);
let loose = Adjustment::controlling_false_discovery_at(0.20);
let borderline = p_values(&[0.03, 0.5]);
assert_eq!(strict.holds_up(&borderline), [false, false]);
assert_eq!(loose.holds_up(&borderline), [true, false]);
}
#[test]
fn an_empty_set_holds_nothing_up_and_does_not_panic() {
let adjustment = Adjustment::controlling_false_discovery_at(0.10);
assert!(adjustment.holds_up(&[]).is_empty());
}
use domain::correlation::{CorrelationStrategy, Family, Tested};
fn tested(family: Family, strategy: CorrelationStrategy, p: f64) -> Tested {
Tested {
family,
strategy,
p_value: PValue::new(p).unwrap(),
}
}
fn measurement(p: f64) -> Tested {
tested(Family::Measurements, CorrelationStrategy::Spearman, p)
}
#[test]
fn a_result_is_corrected_against_the_others_in_its_own_group() {
let adjustment = Adjustment::controlling_false_discovery_at(0.10);
let held = adjustment.holds_up_across(&[measurement(0.04), measurement(0.5)]);
assert_eq!(held, [true, false]);
}
#[test]
fn how_many_activities_are_kept_does_not_change_a_measurement_result() {
let adjustment = Adjustment::controlling_false_discovery_at(0.10);
let mut entries = vec![measurement(0.04), measurement(0.5)];
for _ in 0..20 {
entries.push(tested(
Family::Activities,
CorrelationStrategy::MeanDifference,
0.5,
));
}
let held = adjustment.holds_up_across(&entries);
assert!(
held[0],
"a measurement must not be corrected against activities"
);
}
#[test]
fn measuring_the_same_thing_several_ways_is_not_several_hypotheses() {
let adjustment = Adjustment::controlling_false_discovery_at(0.10);
let mut entries = vec![measurement(0.04), measurement(0.5)];
for other in [CorrelationStrategy::Pearson, CorrelationStrategy::Kendall] {
for _ in 0..10 {
entries.push(tested(Family::Measurements, other, 0.5));
}
}
let held = adjustment.holds_up_across(&entries);
assert!(
held[0],
"correcting across strategies would penalise measuring carefully"
);
}
#[test]
fn more_of_the_same_question_does_make_a_result_work_harder() {
let adjustment = Adjustment::controlling_false_discovery_at(0.10);
let mut entries = vec![measurement(0.04)];
for _ in 0..20 {
entries.push(measurement(0.5));
}
let held = adjustment.holds_up_across(&entries);
assert!(
!held[0],
"twenty more measurements under the same strategy is twenty more comparisons"
);
}
#[test]
fn two_families_are_corrected_apart_even_under_one_strategy() {
let adjustment = Adjustment::controlling_false_discovery_at(0.10);
let mut entries = vec![measurement(0.04), measurement(0.5)];
for _ in 0..20 {
entries.push(tested(
Family::Activities,
CorrelationStrategy::Spearman,
0.5,
));
}
let held = adjustment.holds_up_across(&entries);
assert!(
held[0],
"a Family is a question: activities never dilute a measurement, whichever strategy scored them"
);
}

View File

@@ -0,0 +1,58 @@
use domain::entry::Date;
use domain::moon::MoonPhase;
fn on(day: &str) -> MoonPhase {
MoonPhase::on(&Date::from_persistence(day.parse().unwrap()))
}
#[test]
fn a_total_solar_eclipse_happens_at_a_new_moon() {
for day in ["2017-08-21", "2024-04-08"] {
let illumination = on(day).illumination();
assert!(
illumination < 0.02,
"{day} should be dark, got {illumination}"
);
}
}
#[test]
fn a_total_lunar_eclipse_happens_at_a_full_moon() {
for day in ["2000-01-21", "2018-01-31", "2019-01-21", "2022-05-16"] {
let illumination = on(day).illumination();
assert!(
illumination > 0.98,
"{day} should be full, got {illumination}"
);
}
}
#[test]
fn illumination_never_leaves_its_bounds() {
let mut day = Date::from_persistence("2024-01-01".parse().unwrap());
for _ in 0..800 {
let illumination = MoonPhase::on(&day).illumination();
assert!((0.0..=1.0).contains(&illumination), "got {illumination}");
day = day.next();
}
}
#[test]
fn the_same_illumination_is_told_apart_by_waxing_and_waning() {
let waxing = on("2024-04-15");
let waning = on("2024-04-30");
assert!((waxing.illumination() - 0.5).abs() < 0.15);
assert!((waning.illumination() - 0.5).abs() < 0.15);
assert_eq!(waxing.name(), "First Quarter");
assert_eq!(waning.name(), "Last Quarter");
}
#[test]
fn the_extremes_are_named_for_what_they_are() {
assert_eq!(on("2024-04-08").name(), "New");
assert_eq!(on("2022-05-16").name(), "Full");
}

View File

@@ -0,0 +1,107 @@
use domain::correlation::{CorrelationStrategy, Observation, PValue};
use domain::entry::{DayMood, Mood};
fn day(mood: Mood) -> DayMood {
DayMood::of(&[mood]).unwrap()
}
fn mood_of(value: u8) -> Mood {
Mood::try_from(value).unwrap()
}
fn loosely_rising() -> Vec<Observation> {
let moods = [3, 1, 4, 2, 3, 5, 2, 4, 3, 5, 4, 3];
moods
.iter()
.enumerate()
.map(|(index, mood)| Observation::new(index as f64 + 1.0, day(mood_of(*mood))))
.collect()
}
fn significance(strategy: CorrelationStrategy, observations: &[Observation]) -> f64 {
strategy
.significance(observations)
.expect("a scored series has a p-value")
.value()
}
#[test]
fn the_familiar_thresholds_come_out_of_the_normal_tail() {
assert!((PValue::from_standard_score(1.96).value() - 0.05).abs() < 1e-4);
assert!((PValue::from_standard_score(1.645).value() - 0.10).abs() < 1e-4);
}
#[test]
fn a_standard_score_of_nothing_is_certain_to_be_nothing() {
assert!((PValue::from_standard_score(0.0).value() - 1.0).abs() < 1e-9);
}
#[test]
fn direction_does_not_change_how_surprising_a_score_is() {
let positive = PValue::from_standard_score(2.5).value();
let negative = PValue::from_standard_score(-2.5).value();
assert!((positive - negative).abs() < 1e-12);
}
#[test]
fn each_strategy_turns_its_own_statistic_into_a_p_value() {
let observations = loosely_rising();
let pearson = significance(CorrelationStrategy::Pearson, &observations);
let spearman = significance(CorrelationStrategy::Spearman, &observations);
let kendall = significance(CorrelationStrategy::Kendall, &observations);
assert!((pearson - 0.198_050_956_8).abs() < 1e-4, "got {pearson}");
assert!((spearman - 0.241_732_162_6).abs() < 1e-4, "got {spearman}");
assert!((kendall - 0.201_603_352_7).abs() < 1e-4, "got {kendall}");
}
#[test]
fn a_relationship_that_holds_all_the_way_is_hard_to_put_down_to_chance() {
let tight: Vec<Observation> = (1..=12)
.map(|day_number| {
let mood = mood_of(((day_number - 1) / 3 + 1) as u8);
Observation::new(day_number as f64, day(mood))
})
.collect();
assert!(significance(CorrelationStrategy::Pearson, &tight) < 0.001);
assert!(significance(CorrelationStrategy::Spearman, &tight) < 0.001);
assert!(significance(CorrelationStrategy::Kendall, &tight) < 0.001);
}
#[test]
fn a_mean_difference_is_judged_by_the_spread_within_each_group() {
let observations: Vec<Observation> = [
(1.0, Mood::Good),
(1.0, Mood::Rad),
(1.0, Mood::Good),
(1.0, Mood::Rad),
(0.0, Mood::Bad),
(0.0, Mood::Meh),
(0.0, Mood::Bad),
(0.0, Mood::Awful),
]
.into_iter()
.map(|(value, mood)| Observation::new(value, day(mood)))
.collect();
let p = significance(CorrelationStrategy::MeanDifference, &observations);
assert!((p - 0.000_000_573_3).abs() < 1e-9, "got {p}");
}
#[test]
fn a_series_that_cannot_be_scored_has_nothing_to_report() {
let unvarying: Vec<Observation> = (1..=10)
.map(|day_number| Observation::new(day_number as f64, day(Mood::Meh)))
.collect();
assert!(
CorrelationStrategy::Spearman
.significance(&unvarying)
.is_none()
);
}

View File

@@ -0,0 +1,117 @@
use domain::correlation::{CorrelationStrategy, Observation};
use domain::entry::{DayMood, Mood};
fn day(mood: Mood) -> DayMood {
DayMood::of(&[mood]).unwrap()
}
fn observations(pairs: &[(f64, Mood)]) -> Vec<Observation> {
pairs
.iter()
.map(|(value, mood)| Observation::new(*value, day(*mood)))
.collect()
}
fn spearman(pairs: &[(f64, Mood)]) -> Option<f64> {
CorrelationStrategy::Spearman
.score(&observations(pairs))
.map(|coefficient| coefficient.value())
}
#[test]
fn a_perfectly_ordered_pair_of_series_scores_one() {
let score = spearman(&[
(1.0, Mood::Awful),
(2.0, Mood::Bad),
(3.0, Mood::Meh),
(4.0, Mood::Good),
])
.unwrap();
assert!((score - 1.0).abs() < 1e-12);
}
#[test]
fn a_perfectly_inverted_pair_of_series_scores_minus_one() {
let score = spearman(&[
(1.0, Mood::Rad),
(2.0, Mood::Good),
(3.0, Mood::Meh),
(4.0, Mood::Bad),
])
.unwrap();
assert!((score + 1.0).abs() < 1e-12);
}
#[test]
fn only_the_order_matters_not_the_distance() {
let gentle = spearman(&[
(1.0, Mood::Awful),
(2.0, Mood::Bad),
(3.0, Mood::Meh),
(4.0, Mood::Good),
])
.unwrap();
let one_enormous_day = spearman(&[
(1.0, Mood::Awful),
(2.0, Mood::Bad),
(3.0, Mood::Meh),
(100_000.0, Mood::Good),
])
.unwrap();
assert!((gentle - one_enormous_day).abs() < f64::EPSILON);
}
#[test]
fn tied_moods_share_their_average_rank_rather_than_taking_the_shortcut() {
let score = spearman(&[
(1_000.0, Mood::Meh),
(2_000.0, Mood::Meh),
(3_000.0, Mood::Meh),
(4_000.0, Mood::Good),
(5_000.0, Mood::Good),
(6_000.0, Mood::Rad),
])
.unwrap();
let tie_corrected = 0.925_820_099_8;
let textbook_shortcut = 0.928_571_428_6;
assert!(
(score - tie_corrected).abs() < 1e-9,
"expected the tie-corrected coefficient, got {score}"
);
assert!(
(score - textbook_shortcut).abs() > 1e-9,
"this is the 6*d^2 shortcut, which is wrong when ranks are tied"
);
}
#[test]
fn a_day_count_below_two_scores_nothing() {
assert!(spearman(&[(1.0, Mood::Good)]).is_none());
assert!(spearman(&[]).is_none());
}
#[test]
fn an_unvarying_series_scores_nothing() {
let same_mood_every_day = spearman(&[
(1.0, Mood::Meh),
(2.0, Mood::Meh),
(3.0, Mood::Meh),
(4.0, Mood::Meh),
]);
let same_value_every_day = spearman(&[
(5_000.0, Mood::Awful),
(5_000.0, Mood::Bad),
(5_000.0, Mood::Good),
(5_000.0, Mood::Rad),
]);
assert!(same_mood_every_day.is_none());
assert!(same_value_every_day.is_none());
}

View File

@@ -0,0 +1,180 @@
use domain::activity::ActivityId;
use domain::correlation::{CorrelationInput, CorrelationStrategy, Observation, SeriesShape};
use domain::entry::{DayMood, Mood};
use domain::metric::MetricKind;
fn day(mood: Mood) -> DayMood {
DayMood::of(&[mood]).unwrap()
}
fn observations(pairs: &[(f64, Mood)]) -> Vec<Observation> {
pairs
.iter()
.map(|(value, mood)| Observation::new(*value, day(*mood)))
.collect()
}
fn score(strategy: CorrelationStrategy, pairs: &[(f64, Mood)]) -> Option<f64> {
strategy
.score(&observations(pairs))
.map(|coefficient| coefficient.value())
}
const TIED: [(f64, Mood); 6] = [
(1_000.0, Mood::Meh),
(2_000.0, Mood::Meh),
(3_000.0, Mood::Meh),
(4_000.0, Mood::Good),
(5_000.0, Mood::Good),
(6_000.0, Mood::Rad),
];
#[test]
fn kendall_corrects_for_ties_rather_than_counting_pairs_alone() {
let score = score(CorrelationStrategy::Kendall, &TIED).unwrap();
let tau_b = 0.856_348_838_6;
let tau_a = 0.733_333_333_3;
assert!((score - tau_b).abs() < 1e-9, "expected tau-b, got {score}");
assert!(
(score - tau_a).abs() > 1e-9,
"this is tau-a, which overstates disagreement when ranks are tied"
);
}
#[test]
fn kendall_reaches_both_extremes() {
let rising = score(
CorrelationStrategy::Kendall,
&[
(1.0, Mood::Awful),
(2.0, Mood::Bad),
(3.0, Mood::Meh),
(4.0, Mood::Good),
],
)
.unwrap();
let falling = score(
CorrelationStrategy::Kendall,
&[
(1.0, Mood::Good),
(2.0, Mood::Meh),
(3.0, Mood::Bad),
(4.0, Mood::Awful),
],
)
.unwrap();
assert!((rising - 1.0).abs() < 1e-12);
assert!((falling + 1.0).abs() < 1e-12);
}
#[test]
fn pearson_measures_the_line_where_the_rank_methods_measure_the_order() {
let curved = [
(1.0, Mood::Awful),
(2.0, Mood::Bad),
(3.0, Mood::Meh),
(100.0, Mood::Good),
];
let pearson = score(CorrelationStrategy::Pearson, &curved).unwrap();
let spearman = score(CorrelationStrategy::Spearman, &curved).unwrap();
assert!((spearman - 1.0).abs() < 1e-12, "the order is perfect");
assert!(
(pearson - 0.785_026_421).abs() < 1e-9,
"the line is not, got {pearson}"
);
}
#[test]
fn a_mean_difference_is_told_as_a_share_of_the_mood_scale() {
let score = score(
CorrelationStrategy::MeanDifference,
&[
(1.0, Mood::Good),
(1.0, Mood::Rad),
(1.0, Mood::Good),
(1.0, Mood::Rad),
(0.0, Mood::Bad),
(0.0, Mood::Meh),
(0.0, Mood::Bad),
(0.0, Mood::Awful),
],
)
.unwrap();
assert!(
(score - 0.625).abs() < 1e-12,
"two and a half mood points out of four, got {score}"
);
}
#[test]
fn a_mean_difference_needs_days_on_both_sides() {
let only_present = score(
CorrelationStrategy::MeanDifference,
&[(1.0, Mood::Good), (1.0, Mood::Rad)],
);
let only_absent = score(
CorrelationStrategy::MeanDifference,
&[(0.0, Mood::Good), (0.0, Mood::Rad)],
);
assert!(only_present.is_none());
assert!(only_absent.is_none());
}
#[test]
fn a_strategy_scores_one_shape_of_series_and_an_input_has_one() {
assert_eq!(
CorrelationStrategy::MeanDifference.scores(),
SeriesShape::Presence
);
assert_eq!(
CorrelationInput::Activity(ActivityId::generate()).series(),
SeriesShape::Presence
);
for continuous in [
CorrelationStrategy::Pearson,
CorrelationStrategy::Spearman,
CorrelationStrategy::Kendall,
] {
assert_eq!(continuous.scores(), SeriesShape::Continuous);
}
assert_eq!(
CorrelationInput::Metric(MetricKind::Steps).series(),
SeriesShape::Continuous
);
assert_eq!(
CorrelationInput::MoonPhase.series(),
SeriesShape::Continuous
);
}
#[test]
fn only_matching_strategies_are_offered_for_an_input() {
let steps = CorrelationInput::Metric(MetricKind::Steps);
let exercise = CorrelationInput::Activity(ActivityId::generate());
let for_steps: Vec<&str> = CorrelationStrategy::ALL
.into_iter()
.filter(|strategy| strategy.can_score(&steps))
.map(|strategy| strategy.name())
.collect();
let for_exercise: Vec<&str> = CorrelationStrategy::ALL
.into_iter()
.filter(|strategy| strategy.can_score(&exercise))
.map(|strategy| strategy.name())
.collect();
assert_eq!(for_steps, ["pearson", "spearman", "kendall"]);
assert_eq!(for_exercise, ["meanDifference"]);
}

View File

@@ -0,0 +1,2 @@
#[path = "cycle/cycle_calendar_test.rs"]
mod cycle_calendar_test;

View File

@@ -0,0 +1,139 @@
use domain::cycle::CycleCalendar;
use domain::entry::Date;
fn on(day: &str) -> Date {
Date::from_persistence(day.parse().unwrap())
}
fn calendar(starts: &[&str]) -> CycleCalendar {
CycleCalendar::new(starts.iter().map(|day| on(day)).collect())
}
#[test]
fn the_day_a_cycle_starts_is_its_first_day() {
let position = calendar(&["2026-01-01"])
.position_on(&on("2026-01-01"))
.unwrap();
assert_eq!(position.day().value(), 1);
assert!(position.progress().abs() < f64::EPSILON);
}
#[test]
fn a_date_before_anything_was_recorded_has_no_place_in_a_cycle() {
assert!(
calendar(&["2026-02-01"])
.position_on(&on("2026-01-15"))
.is_none()
);
}
#[test]
fn nothing_recorded_at_all_means_nothing_derived() {
assert!(calendar(&[]).position_on(&on("2026-01-15")).is_none());
}
#[test]
fn a_closed_cycle_is_measured_by_its_own_length() {
let starts = calendar(&["2026-01-01", "2026-01-25"]);
let halfway = starts.position_on(&on("2026-01-13")).unwrap();
assert_eq!(halfway.day().value(), 13);
assert!(
(halfway.progress() - 0.5).abs() < 1e-12,
"twelve days into a twenty-four day cycle, got {}",
halfway.progress()
);
}
#[test]
fn the_cycle_still_running_is_measured_against_the_usual_length() {
let starts = calendar(&["2026-01-01", "2026-01-21", "2026-02-10", "2026-03-02"]);
let open = starts.position_on(&on("2026-03-12")).unwrap();
assert_eq!(open.day().value(), 11);
assert!(
(open.progress() - 0.5).abs() < 1e-12,
"ten days into a twenty-day cycle, got {}",
open.progress()
);
}
#[test]
fn with_nothing_to_go_on_the_usual_length_is_assumed() {
let starts = calendar(&["2026-01-01"]);
let position = starts.position_on(&on("2026-01-15")).unwrap();
assert_eq!(position.day().value(), 15);
assert!(
(position.progress() - 0.5).abs() < 1e-12,
"fourteen days into an assumed twenty-eight day cycle, got {}",
position.progress()
);
}
#[test]
fn correcting_the_start_moves_every_day_that_depended_on_it() {
let before = calendar(&["2026-01-01"])
.position_on(&on("2026-01-10"))
.unwrap();
let after = calendar(&["2026-01-03"])
.position_on(&on("2026-01-10"))
.unwrap();
assert_eq!(before.day().value(), 10);
assert_eq!(after.day().value(), 8);
}
#[test]
fn a_gap_too_long_to_be_one_cycle_means_a_start_was_missed() {
let position = calendar(&["2026-01-01"]).position_on(&on("2026-06-01"));
assert!(
position.is_none(),
"a hundred and fifty days is a missing record, not a long cycle"
);
}
#[test]
fn the_order_and_repetition_of_what_was_recorded_does_not_matter() {
let tidy = calendar(&["2026-01-01", "2026-01-25"]);
let messy = calendar(&["2026-01-25", "2026-01-01", "2026-01-25"]);
let from_tidy = tidy.position_on(&on("2026-01-13")).unwrap();
let from_messy = messy.position_on(&on("2026-01-13")).unwrap();
assert_eq!(from_tidy.day().value(), from_messy.day().value());
assert!((from_tidy.progress() - from_messy.progress()).abs() < f64::EPSILON);
}
#[test]
fn the_last_day_of_a_cycle_has_run_its_whole_course() {
let starts = calendar(&["2026-01-01", "2026-01-29"]);
let last = starts.position_on(&on("2026-01-28")).unwrap();
assert_eq!(last.day().value(), 28);
assert!(
(last.progress() - 27.0 / 28.0).abs() < 1e-12,
"got {}",
last.progress()
);
}
#[test]
fn progress_never_runs_past_the_end_of_a_cycle() {
let starts = calendar(&["2026-01-01", "2026-01-15", "2026-01-29"]);
let overdue = starts.position_on(&on("2026-02-28")).unwrap();
assert_eq!(overdue.day().value(), 31);
assert!(
(overdue.progress() - 1.0).abs() < f64::EPSILON,
"a late cycle sits at its end rather than beyond it, got {}",
overdue.progress()
);
}

View File

@@ -0,0 +1,2 @@
#[path = "dimension/dimension_value_test.rs"]
mod dimension_value_test;

View File

@@ -0,0 +1,30 @@
use domain::activity::ActivityId;
use domain::dimension::{DimensionKind, DimensionValue};
#[test]
fn activities_are_deduplicated_and_sorted() {
let first = ActivityId::generate();
let second = ActivityId::generate();
let value = DimensionValue::activities(vec![second.clone(), first.clone(), second.clone()]);
let DimensionValue::Activities(ids) = value else {
panic!("expected an activities dimension");
};
assert_eq!(ids.len(), 2);
assert!(ids[0] <= ids[1]);
}
#[test]
fn every_value_reports_its_own_kind() {
assert_eq!(
DimensionValue::activities(vec![]).kind(),
DimensionKind::Activities
);
assert_eq!(DimensionValue::Photos(vec![]).kind(), DimensionKind::Photos);
assert_eq!(
DimensionValue::VoiceMemos(vec![]).kind(),
DimensionKind::VoiceMemos
);
}

View File

@@ -9,3 +9,9 @@ mod mood_entry_test;
#[path = "entry/date_range_test.rs"]
mod date_range_test;
#[path = "entry/date_test.rs"]
mod date_test;
#[path = "entry/day_mood_test.rs"]
mod day_mood_test;

View File

@@ -0,0 +1,58 @@
use chrono::{DateTime, FixedOffset};
use domain::entry::Date;
use domain::user::Timezone;
fn instant(text: &str) -> DateTime<FixedOffset> {
text.parse().unwrap()
}
fn zone(name: &str) -> Timezone {
Timezone::new(name).unwrap()
}
#[test]
fn an_instant_resolves_to_the_calendar_day_in_the_users_zone() {
let date = Date::from_instant(
&instant("2025-03-15T09:00:00+13:00"),
&zone("Pacific/Auckland"),
);
assert_eq!(date.value().to_string(), "2025-03-15");
}
#[test]
fn the_users_zone_decides_the_day_not_the_offset_the_client_sent() {
let same_moment = instant("2025-03-15T23:00:00+00:00");
let in_auckland = Date::from_instant(&same_moment, &zone("Pacific/Auckland"));
let in_warsaw = Date::from_instant(&same_moment, &zone("Europe/Warsaw"));
assert_eq!(in_auckland.value().to_string(), "2025-03-16");
assert_eq!(in_warsaw.value().to_string(), "2025-03-16");
}
#[test]
fn an_evening_in_a_far_eastern_zone_is_not_the_utc_day() {
let evening = instant("2025-03-14T20:00:00+00:00");
let date = Date::from_instant(&evening, &zone("Pacific/Auckland"));
assert_eq!(date.value().to_string(), "2025-03-15");
}
#[test]
fn a_day_knows_the_day_before_it() {
let date = Date::from_instant(&instant("2025-03-01T12:00:00+00:00"), &zone("UTC"));
assert_eq!(date.previous().value().to_string(), "2025-02-28");
}
#[test]
fn days_between_two_dates_is_their_gap() {
let earlier = Date::from_instant(&instant("2025-03-01T12:00:00+00:00"), &zone("UTC"));
let later = Date::from_instant(&instant("2025-03-05T12:00:00+00:00"), &zone("UTC"));
assert_eq!(later.days_since(&earlier), 4);
assert_eq!(earlier.days_since(&later), -4);
}

View File

@@ -0,0 +1,57 @@
use domain::entry::{DayMood, Mood};
#[test]
fn a_day_of_one_awful_and_one_rad_sits_in_the_middle() {
let day = DayMood::of(&[Mood::Awful, Mood::Rad]).unwrap();
assert!((day.value() - 3.0).abs() < f64::EPSILON);
assert_eq!(day.rounded(), Mood::Meh);
}
#[test]
fn a_day_with_no_entries_has_no_mood() {
assert!(DayMood::of(&[]).is_none());
}
#[test]
fn one_bad_day_among_four_middling_ones_still_shows() {
let mostly_meh = DayMood::of(&[Mood::Meh, Mood::Meh, Mood::Meh, Mood::Meh, Mood::Awful])
.unwrap()
.value();
let all_meh = DayMood::of(&[Mood::Meh, Mood::Meh, Mood::Meh, Mood::Meh, Mood::Meh])
.unwrap()
.value();
assert!(mostly_meh < all_meh);
}
#[test]
fn a_single_entry_is_the_whole_day() {
let day = DayMood::of(&[Mood::Bad]).unwrap();
assert!((day.value() - 2.0).abs() < f64::EPSILON);
assert_eq!(day.rounded(), Mood::Bad);
}
#[test]
fn a_day_exactly_between_two_moods_shows_the_better_one() {
let day = DayMood::of(&[Mood::Meh, Mood::Good]).unwrap();
assert!((day.value() - 3.5).abs() < f64::EPSILON);
assert_eq!(day.rounded(), Mood::Good);
}
#[test]
fn every_whole_mean_rounds_to_its_own_mood() {
let pairs = [
(Mood::Awful, Mood::Awful),
(Mood::Bad, Mood::Bad),
(Mood::Meh, Mood::Meh),
(Mood::Good, Mood::Good),
(Mood::Rad, Mood::Rad),
];
for (logged, expected) in pairs {
assert_eq!(DayMood::of(&[logged, logged]).unwrap().rounded(), expected);
}
}

View File

@@ -1,8 +1,6 @@
use chrono::{FixedOffset, TimeZone, Utc};
use domain::activity::ActivityId;
use domain::attachment::{PhotoId, VoiceMemoId};
use domain::entry::{Content, Mood, MoodEntry, MoodEntryData};
use domain::entry::{Mood, MoodEntry, MoodEntryData};
use domain::user::UserId;
fn sample_logged_at() -> chrono::DateTime<FixedOffset> {
@@ -19,10 +17,6 @@ fn new_entry_has_required_fields() {
assert_eq!(*entry.user_id(), user_id);
assert_eq!(entry.mood(), Mood::Good);
assert!(entry.activities().is_empty());
assert!(entry.content().is_none());
assert!(entry.photos().is_empty());
assert!(entry.voice_memos().is_empty());
}
#[test]
@@ -36,39 +30,10 @@ fn update_mood_changes_value_and_touches_updated_at() {
assert!(*entry.updated_at() >= before);
}
#[test]
fn set_activities_deduplicates_and_sorts() {
let mut entry = MoodEntry::new(UserId::generate(), Mood::Meh, sample_logged_at());
let a = ActivityId::generate();
let b = ActivityId::generate();
entry.set_activities(vec![b.clone(), a.clone(), b.clone()]);
assert_eq!(entry.activities().len(), 2);
let ids = entry.activities();
assert!(ids[0] <= ids[1]);
}
#[test]
fn set_content_updates_and_clears() {
let mut entry = MoodEntry::new(UserId::generate(), Mood::Good, sample_logged_at());
let content = Content::new("great day").unwrap();
entry.set_content(Some(content));
assert!(entry.content().is_some());
entry.set_content(None);
assert!(entry.content().is_none());
}
#[test]
fn from_persistence_reconstructs_all_fields() {
let id = domain::entry::MoodEntryId::generate();
let user_id = UserId::generate();
let activity_id = ActivityId::generate();
let photo_id = PhotoId::generate();
let voice_memo_id = VoiceMemoId::generate();
let content = Content::new("test").unwrap();
let now = Utc::now();
let entry = MoodEntry::from_persistence(MoodEntryData {
@@ -76,10 +41,6 @@ fn from_persistence_reconstructs_all_fields() {
user_id: user_id.clone(),
mood: Mood::Bad,
logged_at: sample_logged_at(),
activities: vec![activity_id.clone()],
content: Some(content),
photos: vec![photo_id.clone()],
voice_memos: vec![voice_memo_id.clone()],
created_at: now,
updated_at: now,
});
@@ -87,10 +48,7 @@ fn from_persistence_reconstructs_all_fields() {
assert_eq!(*entry.id(), id);
assert_eq!(*entry.user_id(), user_id);
assert_eq!(entry.mood(), Mood::Bad);
assert_eq!(entry.activities().len(), 1);
assert!(entry.content().is_some());
assert_eq!(entry.photos().len(), 1);
assert_eq!(entry.voice_memos().len(), 1);
assert_eq!(*entry.logged_at(), sample_logged_at());
}
#[test]
@@ -105,32 +63,3 @@ fn update_logged_at_changes_timestamp() {
assert_eq!(*entry.logged_at(), new_time);
}
#[test]
fn set_photos_replaces_list() {
let mut entry = MoodEntry::new(UserId::generate(), Mood::Good, sample_logged_at());
let p1 = PhotoId::generate();
let p2 = PhotoId::generate();
entry.set_photos(vec![p1.clone(), p2.clone()]);
assert_eq!(entry.photos().len(), 2);
entry.set_photos(vec![p1.clone()]);
assert_eq!(entry.photos().len(), 1);
entry.set_photos(vec![]);
assert!(entry.photos().is_empty());
}
#[test]
fn set_voice_memos_replaces_list() {
let mut entry = MoodEntry::new(UserId::generate(), Mood::Good, sample_logged_at());
let m1 = VoiceMemoId::generate();
let m2 = VoiceMemoId::generate();
entry.set_voice_memos(vec![m1.clone(), m2.clone()]);
assert_eq!(entry.voice_memos().len(), 2);
entry.set_voice_memos(vec![]);
assert!(entry.voice_memos().is_empty());
}

View File

@@ -0,0 +1,2 @@
#[path = "job/job_test.rs"]
mod job_test;

View File

@@ -0,0 +1,75 @@
use domain::entry::MoodEntryId;
use domain::job::{Job, JobKind, JobStatus, JobSubject};
fn a_job() -> Job {
Job::pending(
JobKind::BackfillRecordingIdentity,
JobSubject::Entry(MoodEntryId::generate()),
)
}
#[test]
fn a_new_job_is_waiting_and_has_never_been_tried() {
let job = a_job();
assert_eq!(job.status(), JobStatus::Pending);
assert_eq!(job.attempts(), 0);
assert!(job.last_error().is_none());
}
#[test]
fn a_job_with_attempts_left_is_worth_trying_again() {
let mut job = a_job();
job.attempted();
assert_eq!(job.attempts(), 1);
assert!(job.is_worth_another_attempt(3));
}
#[test]
fn a_job_that_has_used_every_attempt_is_not_tried_again() {
let mut job = a_job();
for _ in 0..3 {
job.attempted();
}
assert_eq!(job.attempts(), 3);
assert!(!job.is_worth_another_attempt(3));
}
#[test]
fn every_status_survives_a_round_trip_through_its_name() {
for status in [JobStatus::Pending, JobStatus::Running, JobStatus::Exhausted] {
assert_eq!(JobStatus::from_name(status.name()), Some(status));
}
assert_eq!(JobStatus::from_name("halfway"), None);
}
#[test]
fn every_kind_survives_a_round_trip_through_its_name() {
for kind in JobKind::ALL {
assert_eq!(JobKind::from_name(kind.name()), Some(kind));
}
assert_eq!(JobKind::from_name("summon-rain"), None);
}
#[test]
fn a_subject_names_the_thing_the_work_is_about() {
let entry_id = MoodEntryId::generate();
let subject = JobSubject::Entry(entry_id.clone());
assert_eq!(subject.key(), entry_id.value().to_string());
assert_eq!(
JobSubject::from_key(subject.key().as_str()),
Some(JobSubject::Entry(entry_id))
);
}
#[test]
fn a_subject_that_is_not_an_identity_names_nothing() {
assert!(JobSubject::from_key("not-a-uuid").is_none());
}

View File

@@ -0,0 +1,2 @@
#[path = "location/coordinates_test.rs"]
mod coordinates_test;

View File

@@ -0,0 +1,35 @@
use domain::location::{Coordinates, Latitude, Longitude};
#[test]
fn coordinates_are_built_from_valid_bounds() {
let coordinates = Coordinates::new(52.2297, 21.0122).unwrap();
assert!((coordinates.latitude().value() - 52.2297).abs() < f64::EPSILON);
assert!((coordinates.longitude().value() - 21.0122).abs() < f64::EPSILON);
}
#[test]
fn the_poles_and_the_antimeridian_are_valid() {
assert!(Latitude::new(90.0).is_ok());
assert!(Latitude::new(-90.0).is_ok());
assert!(Longitude::new(180.0).is_ok());
assert!(Longitude::new(-180.0).is_ok());
}
#[test]
fn a_latitude_beyond_the_poles_is_rejected() {
assert!(Latitude::new(90.1).is_err());
assert!(Latitude::new(-90.1).is_err());
}
#[test]
fn a_longitude_beyond_the_antimeridian_is_rejected() {
assert!(Longitude::new(180.1).is_err());
assert!(Longitude::new(-180.1).is_err());
}
#[test]
fn a_coordinate_that_is_not_a_number_is_rejected() {
assert!(Latitude::new(f64::NAN).is_err());
assert!(Longitude::new(f64::INFINITY).is_err());
}

View File

@@ -0,0 +1,32 @@
#[path = "metric/steps_test.rs"]
mod steps_test;
#[path = "metric/metric_value_test.rs"]
mod metric_value_test;
#[path = "metric/daily_metric_test.rs"]
mod daily_metric_test;
#[path = "metric/sleep_minutes_test.rs"]
mod sleep_minutes_test;
#[path = "metric/awake_minutes_test.rs"]
mod awake_minutes_test;
#[path = "metric/resting_heart_rate_test.rs"]
mod resting_heart_rate_test;
#[path = "metric/hrv_test.rs"]
mod hrv_test;
#[path = "metric/exercise_minutes_test.rs"]
mod exercise_minutes_test;
#[path = "metric/screen_time_minutes_test.rs"]
mod screen_time_minutes_test;
#[path = "metric/alcoholic_drinks_test.rs"]
mod alcoholic_drinks_test;
#[path = "metric/metric_kind_test.rs"]
mod metric_kind_test;

View File

@@ -0,0 +1,12 @@
use domain::metric::AlcoholicDrinks;
#[test]
fn a_count_of_drinks_no_one_survives_is_rejected() {
assert!(AlcoholicDrinks::new(61).is_err());
}
#[test]
fn the_boundaries_of_a_days_drinking_are_valid() {
assert_eq!(AlcoholicDrinks::new(0).unwrap().value(), 0);
assert_eq!(AlcoholicDrinks::new(60).unwrap().value(), 60);
}

View File

@@ -0,0 +1,12 @@
use domain::metric::AwakeMinutes;
#[test]
fn more_awake_minutes_than_a_day_holds_are_rejected() {
assert!(AwakeMinutes::new(1_441).is_err());
}
#[test]
fn the_boundaries_of_time_spent_awake_are_valid() {
assert_eq!(AwakeMinutes::new(0).unwrap().value(), 0);
assert_eq!(AwakeMinutes::new(1_440).unwrap().value(), 1_440);
}

View File

@@ -0,0 +1,53 @@
use domain::entry::Date;
use domain::metric::{DailyMetric, MetricValue, Source, Steps};
use domain::provider::ProviderName;
use domain::user::UserId;
fn a_date() -> Date {
Date::from_persistence("2026-08-26".parse().unwrap())
}
fn steps(count: u32, source: Source) -> DailyMetric {
DailyMetric::new(
UserId::generate(),
a_date(),
MetricValue::Steps(Steps::new(count).unwrap()),
source,
)
}
fn a_provider() -> Source {
Source::Provider(ProviderName::new("healthkit").unwrap())
}
#[test]
fn a_manual_count_supersedes_one_a_provider_reported() {
let manual = steps(8_412, Source::Manual);
let imported = steps(8_000, a_provider());
assert!(manual.supersedes(&imported));
}
#[test]
fn a_provider_does_not_overwrite_a_count_the_user_stated() {
let manual = steps(8_412, Source::Manual);
let imported = steps(8_000, a_provider());
assert!(!imported.supersedes(&manual));
}
#[test]
fn a_later_import_replaces_an_earlier_one() {
let earlier = steps(8_000, a_provider());
let later = steps(8_412, a_provider());
assert!(later.supersedes(&earlier));
}
#[test]
fn a_correction_replaces_the_count_the_user_stated_before() {
let stated = steps(8_000, Source::Manual);
let corrected = steps(8_412, Source::Manual);
assert!(corrected.supersedes(&stated));
}

View File

@@ -0,0 +1,12 @@
use domain::metric::ExerciseMinutes;
#[test]
fn more_exercise_than_a_day_holds_is_rejected() {
assert!(ExerciseMinutes::new(1_441).is_err());
}
#[test]
fn the_boundaries_of_a_days_exercise_are_valid() {
assert_eq!(ExerciseMinutes::new(0).unwrap().value(), 0);
assert_eq!(ExerciseMinutes::new(1_440).unwrap().value(), 1_440);
}

View File

@@ -0,0 +1,17 @@
use domain::metric::Hrv;
#[test]
fn a_variability_of_zero_is_rejected() {
assert!(Hrv::new(0).is_err());
}
#[test]
fn a_variability_beyond_any_recorded_reading_is_rejected() {
assert!(Hrv::new(301).is_err());
}
#[test]
fn the_boundaries_of_variability_are_valid() {
assert_eq!(Hrv::new(1).unwrap().value(), 1);
assert_eq!(Hrv::new(300).unwrap().value(), 300);
}

View File

@@ -0,0 +1,45 @@
use domain::metric::MetricKind;
const EVERY_KIND: [MetricKind; 8] = [
MetricKind::Steps,
MetricKind::SleepMinutes,
MetricKind::AwakeMinutes,
MetricKind::RestingHeartRate,
MetricKind::Hrv,
MetricKind::ExerciseMinutes,
MetricKind::ScreenTimeMinutes,
MetricKind::AlcoholicDrinks,
];
fn wire_name(kind: MetricKind) -> &'static str {
match kind {
MetricKind::Steps => "steps",
MetricKind::SleepMinutes => "sleepMinutes",
MetricKind::AwakeMinutes => "awakeMinutes",
MetricKind::RestingHeartRate => "restingHeartRate",
MetricKind::Hrv => "hrv",
MetricKind::ExerciseMinutes => "exerciseMinutes",
MetricKind::ScreenTimeMinutes => "screenTimeMinutes",
MetricKind::AlcoholicDrinks => "alcoholicDrinks",
}
}
#[test]
fn every_kind_keeps_the_name_clients_and_rows_already_use() {
for kind in EVERY_KIND {
assert_eq!(kind.name(), wire_name(kind));
}
}
#[test]
fn every_name_resolves_back_to_its_kind() {
for kind in EVERY_KIND {
assert_eq!(MetricKind::from_name(kind.name()), Some(kind));
}
}
#[test]
fn a_name_this_build_does_not_know_resolves_to_nothing() {
assert_eq!(MetricKind::from_name("telepathy"), None);
assert_eq!(MetricKind::from_name("caffeine"), None);
}

View File

@@ -0,0 +1,8 @@
use domain::metric::{MetricKind, MetricValue, Steps};
#[test]
fn a_values_kind_is_derived_from_the_value_itself() {
let value = MetricValue::Steps(Steps::new(8_412).unwrap());
assert_eq!(value.kind(), MetricKind::Steps);
}

View File

@@ -0,0 +1,13 @@
use domain::metric::RestingHeartRate;
#[test]
fn a_resting_rate_no_living_person_has_is_rejected() {
assert!(RestingHeartRate::new(24).is_err());
assert!(RestingHeartRate::new(121).is_err());
}
#[test]
fn the_range_covers_athletes_and_the_unwell() {
assert_eq!(RestingHeartRate::new(25).unwrap().value(), 25);
assert_eq!(RestingHeartRate::new(120).unwrap().value(), 120);
}

View File

@@ -0,0 +1,12 @@
use domain::metric::ScreenTimeMinutes;
#[test]
fn more_screen_time_than_a_day_holds_is_rejected() {
assert!(ScreenTimeMinutes::new(1_441).is_err());
}
#[test]
fn the_boundaries_of_a_days_screen_time_are_valid() {
assert_eq!(ScreenTimeMinutes::new(0).unwrap().value(), 0);
assert_eq!(ScreenTimeMinutes::new(1_440).unwrap().value(), 1_440);
}

View File

@@ -0,0 +1,12 @@
use domain::metric::SleepMinutes;
#[test]
fn a_night_longer_than_a_day_is_rejected() {
assert!(SleepMinutes::new(1_441).is_err());
}
#[test]
fn the_boundaries_of_a_nights_sleep_are_valid() {
assert_eq!(SleepMinutes::new(0).unwrap().value(), 0);
assert_eq!(SleepMinutes::new(1_440).unwrap().value(), 1_440);
}

View File

@@ -0,0 +1,12 @@
use domain::metric::Steps;
#[test]
fn a_step_count_beyond_a_days_maximum_is_rejected() {
assert!(Steps::new(200_001).is_err());
}
#[test]
fn the_boundaries_of_a_days_step_count_are_valid() {
assert_eq!(Steps::new(0).unwrap().value(), 0);
assert_eq!(Steps::new(200_000).unwrap().value(), 200_000);
}

View File

@@ -0,0 +1,2 @@
#[path = "provider/provider_connection_test.rs"]
mod provider_connection_test;

View File

@@ -0,0 +1,55 @@
use domain::provider::{EncryptedCredential, ProviderConnection, ProviderName};
use domain::user::UserId;
fn credential() -> EncryptedCredential {
EncryptedCredential::from_persistence(vec![1, 2, 3, 4])
}
#[test]
fn a_provider_name_is_a_lowercase_slug() {
assert_eq!(ProviderName::new("Subsonic").unwrap().value(), "subsonic");
assert_eq!(ProviderName::new(" spotify ").unwrap().value(), "spotify");
}
#[test]
fn a_blank_or_malformed_provider_name_is_rejected() {
assert!(ProviderName::new("").is_err());
assert!(ProviderName::new(" ").is_err());
assert!(ProviderName::new("my provider").is_err());
}
#[test]
fn a_connection_belongs_to_one_user_and_one_provider() {
let user_id = UserId::generate();
let provider = ProviderName::new("subsonic").unwrap();
let connection = ProviderConnection::new(user_id.clone(), provider.clone(), credential());
assert_eq!(*connection.user_id(), user_id);
assert_eq!(connection.provider().value(), "subsonic");
}
#[test]
fn a_credential_never_reveals_itself_in_debug_output() {
let user_id = UserId::generate();
let provider = ProviderName::new("subsonic").unwrap();
let secret = EncryptedCredential::from_persistence(b"hunter2".to_vec());
let connection = ProviderConnection::new(user_id, provider, secret);
let rendered = format!("{connection:?}");
assert!(!rendered.contains("hunter2"));
assert!(!rendered.contains("104, 117, 110"));
}
#[test]
fn replacing_a_credential_touches_the_connection() {
let user_id = UserId::generate();
let provider = ProviderName::new("subsonic").unwrap();
let mut connection = ProviderConnection::new(user_id, provider, credential());
let before = *connection.updated_at();
connection.replace_credential(EncryptedCredential::from_persistence(vec![9, 9]));
assert!(*connection.updated_at() >= before);
}

View File

@@ -1,8 +1,9 @@
use chrono::{Duration, FixedOffset, TimeZone};
use domain::activity::ActivityId;
use domain::entry::Date;
use domain::entry::{Mood, MoodEntry};
use domain::services::MoodAnalyzerService;
use domain::user::Timezone;
use domain::user::UserId;
fn entry_with_mood(mood: Mood, days_ago: i64) -> MoodEntry {
@@ -12,14 +13,6 @@ fn entry_with_mood(mood: Mood, days_ago: i64) -> MoodEntry {
MoodEntry::new(UserId::generate(), mood, logged_at)
}
fn entry_with_mood_and_activity(mood: Mood, activity: &ActivityId) -> MoodEntry {
let offset = FixedOffset::east_opt(0).unwrap();
let logged_at = offset.from_utc_datetime(&chrono::Utc::now().naive_utc());
let mut entry = MoodEntry::new(UserId::generate(), mood, logged_at);
entry.set_activities(vec![activity.clone()]);
entry
}
#[test]
fn average_mood_of_empty_slice_is_none() {
assert!(MoodAnalyzerService::average_mood(&[]).is_none());
@@ -61,61 +54,43 @@ fn mood_frequency_counts_each_mood() {
#[test]
fn streak_counts_consecutive_days() {
let entries = vec![
entry_with_mood(Mood::Good, 0),
entry_with_mood(Mood::Good, 1),
entry_with_mood(Mood::Good, 2),
];
let dates = dates_ago(&[0, 1, 2]);
assert_eq!(MoodAnalyzerService::current_streak(&entries), 3);
assert_eq!(MoodAnalyzerService::current_streak(&dates, today()), 3);
}
#[test]
fn streak_breaks_on_gap() {
let entries = vec![
entry_with_mood(Mood::Good, 0),
entry_with_mood(Mood::Good, 1),
entry_with_mood(Mood::Good, 3),
];
let dates = dates_ago(&[0, 1, 3]);
assert_eq!(MoodAnalyzerService::current_streak(&entries), 2);
assert_eq!(MoodAnalyzerService::current_streak(&dates, today()), 2);
}
#[test]
fn streak_of_empty_entries_is_zero() {
assert_eq!(MoodAnalyzerService::current_streak(&[]), 0);
fn streak_of_no_dates_is_zero() {
assert_eq!(MoodAnalyzerService::current_streak(&[], today()), 0);
}
#[test]
fn activity_correlation_positive_when_mood_higher_with_activity() {
let activity = ActivityId::generate();
let entries = vec![
entry_with_mood_and_activity(Mood::Rad, &activity),
entry_with_mood_and_activity(Mood::Good, &activity),
entry_with_mood(Mood::Meh, 0),
entry_with_mood(Mood::Bad, 1),
];
fn streak_is_zero_when_the_last_entry_is_old() {
let dates = dates_ago(&[3, 4]);
let correlation = MoodAnalyzerService::activity_mood_correlation(&entries, &activity).unwrap();
assert!(correlation > 0.0);
assert_eq!(MoodAnalyzerService::current_streak(&dates, today()), 0);
}
#[test]
fn activity_correlation_is_none_when_never_used() {
let activity = ActivityId::generate();
let entries = vec![entry_with_mood(Mood::Good, 0)];
fn the_streak_is_measured_in_the_users_own_days_not_utc_days() {
// 2025-03-15T20:00Z is already the 16th in Auckland and still the 15th in UTC.
let evening: chrono::DateTime<FixedOffset> = "2025-03-15T20:00:00+00:00".parse().unwrap();
let auckland = Timezone::new("Pacific/Auckland").unwrap();
let result = MoodAnalyzerService::activity_mood_correlation(&entries, &activity);
assert!(result.is_none());
}
let logged = Date::from_instant(&evening, &auckland);
let their_today = Date::from_instant(&evening, &auckland);
#[test]
fn streak_is_zero_when_last_entry_is_old() {
let entries = vec![
entry_with_mood(Mood::Good, 3),
entry_with_mood(Mood::Good, 4),
];
assert_eq!(MoodAnalyzerService::current_streak(&entries), 0);
assert_eq!(
MoodAnalyzerService::current_streak(&[logged], their_today),
1
);
}
#[test]
@@ -124,14 +99,20 @@ fn mood_frequency_on_empty_is_empty() {
assert!(freq.is_empty());
}
#[test]
fn activity_correlation_is_none_when_all_entries_have_activity() {
let activity = ActivityId::generate();
let entries = vec![
entry_with_mood_and_activity(Mood::Good, &activity),
entry_with_mood_and_activity(Mood::Rad, &activity),
];
let result = MoodAnalyzerService::activity_mood_correlation(&entries, &activity);
assert!(result.is_none());
fn today() -> Date {
Date::from_instant(
&chrono::Utc::now().fixed_offset(),
&Timezone::new("UTC").unwrap(),
)
}
fn dates_ago(offsets: &[i64]) -> Vec<Date> {
let utc = Timezone::new("UTC").unwrap();
offsets
.iter()
.map(|days| {
let instant = chrono::Utc::now() - chrono::Duration::days(*days);
Date::from_instant(&instant.fixed_offset(), &utc)
})
.collect()
}

View File

@@ -0,0 +1,2 @@
#[path = "song/song_test.rs"]
mod song_test;

View File

@@ -0,0 +1,44 @@
use domain::song::{RecordingId, Song};
#[test]
fn a_song_needs_only_a_title_and_an_artist() {
let song = Song::new("Paranoid Android", "Radiohead", None, None).unwrap();
assert_eq!(song.title().value(), "Paranoid Android");
assert_eq!(song.artist().value(), "Radiohead");
assert!(song.album().is_none());
assert!(song.recording_id().is_none());
}
#[test]
fn a_song_records_its_album_and_external_identity_when_known() {
let recording = RecordingId::new("f5c7e7a2-0000-4000-8000-000000000001").unwrap();
let song = Song::new(
"Paranoid Android",
"Radiohead",
Some("OK Computer".into()),
Some(recording),
)
.unwrap();
assert_eq!(song.album().map(|a| a.value()), Some("OK Computer"));
assert!(song.recording_id().is_some());
}
#[test]
fn a_song_without_a_title_or_artist_is_rejected() {
assert!(Song::new("", "Radiohead", None, None).is_err());
assert!(Song::new("Paranoid Android", " ", None, None).is_err());
}
#[test]
fn a_recording_id_that_is_not_a_uuid_is_rejected() {
assert!(RecordingId::new("not-a-musicbrainz-id").is_err());
}
#[test]
fn a_blank_album_is_treated_as_absent() {
let song = Song::new("Airbag", "Radiohead", Some(" ".into()), None).unwrap();
assert!(song.album().is_none());
}

View File

@@ -2,31 +2,52 @@ use domain::errors::DomainError;
use domain::user::Timezone;
#[test]
fn valid_timezone_is_created() {
let tz = Timezone::new("Europe/Warsaw").unwrap();
assert_eq!(tz.value(), "Europe/Warsaw");
fn a_zone_from_the_iana_database_is_accepted() {
assert_eq!(
Timezone::new("Europe/Warsaw").unwrap().value(),
"Europe/Warsaw"
);
assert_eq!(
Timezone::new("America/New_York").unwrap().value(),
"America/New_York"
);
}
#[test]
fn timezone_trims_whitespace() {
let tz = Timezone::new(" America/New_York ").unwrap();
assert_eq!(tz.value(), "America/New_York");
fn utc_is_a_real_zone_and_is_accepted() {
assert_eq!(Timezone::new("UTC").unwrap().value(), "UTC");
}
#[test]
fn empty_timezone_is_rejected() {
let result = Timezone::new("");
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
fn surrounding_whitespace_is_trimmed() {
assert_eq!(
Timezone::new(" America/New_York ").unwrap().value(),
"America/New_York"
);
}
#[test]
fn timezone_without_slash_is_rejected() {
let result = Timezone::new("UTC");
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
fn a_zone_that_merely_looks_like_one_is_rejected() {
assert!(matches!(
Timezone::new("Foo/Bar"),
Err(DomainError::InvalidInput(_))
));
assert!(matches!(
Timezone::new("Europe/Atlantis"),
Err(DomainError::InvalidInput(_))
));
}
#[test]
fn from_persistence_bypasses_validation() {
let tz = Timezone::from_persistence("UTC".into());
assert_eq!(tz.value(), "UTC");
fn an_empty_zone_is_rejected() {
assert!(matches!(
Timezone::new(""),
Err(DomainError::InvalidInput(_))
));
}
#[test]
fn a_stored_zone_that_cannot_be_resolved_is_refused_on_load() {
assert!(Timezone::from_persistence("Foo/Bar").is_err());
assert!(Timezone::from_persistence("Europe/Warsaw").is_ok());
}

View File

@@ -0,0 +1,2 @@
#[path = "weather/weather_test.rs"]
mod weather_test;

View File

@@ -0,0 +1,60 @@
use domain::provider::ProviderName;
use domain::weather::{Celsius, Condition, Weather};
fn open_meteo() -> ProviderName {
ProviderName::new("open-meteo").unwrap()
}
#[test]
fn a_temperature_nowhere_on_earth_reaches_is_rejected() {
assert!(Celsius::new(-95.0).is_err());
assert!(Celsius::new(65.0).is_err());
}
#[test]
fn the_coldest_and_hottest_places_on_earth_are_valid() {
assert!(Celsius::new(-90.0).is_ok());
assert!(Celsius::new(60.0).is_ok());
}
#[test]
fn a_temperature_that_is_not_a_number_is_rejected() {
assert!(Celsius::new(f64::NAN).is_err());
assert!(Celsius::new(f64::INFINITY).is_err());
}
#[test]
fn every_condition_survives_a_round_trip_through_its_name() {
for condition in Condition::ALL {
assert_eq!(Condition::from_name(condition.name()), Some(condition));
}
assert_eq!(Condition::from_name("raining cats"), None);
}
#[test]
fn the_vocabulary_is_the_one_the_domain_chose_not_the_providers() {
let names: Vec<&str> = Condition::ALL.iter().map(|c| c.name()).collect();
assert_eq!(
names,
[
"clear",
"cloudy",
"fog",
"drizzle",
"rain",
"snow",
"thunderstorm"
]
);
}
#[test]
fn weather_is_always_attributed_to_whoever_observed_it() {
let weather = Weather::new(Condition::Rain, Celsius::new(11.5).unwrap(), open_meteo());
assert_eq!(weather.condition(), Condition::Rain);
assert!((weather.temperature().value() - 11.5).abs() < f64::EPSILON);
assert_eq!(weather.observed_by().value(), "open-meteo");
}