Fist commit

This commit is contained in:
Angel Ivan
2026-10-02 12:08:21 -06:00
commit 2a9eabce14
54 changed files with 10737 additions and 0 deletions

11
test/README Executable file
View File

@@ -0,0 +1,11 @@
This directory is intended for PlatformIO Test Runner and project tests.
Unit Testing is a software testing method by which individual units of
source code, sets of one or more MCU program modules together with associated
control data, usage procedures, and operating procedures, are tested to
determine whether they are fit for use. Unit testing finds problems early
in the development cycle.
More information about PlatformIO Unit Testing:
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html

View File

@@ -0,0 +1,15 @@
#pragma once
#include "SessionClock.h"
class FakeMonotonicClock final : public timbre_core::MonotonicClock {
public:
explicit FakeMonotonicClock(std::uint64_t initial = 0) : value_(initial) {}
std::uint64_t nowMs() const override { return value_; }
void advance(std::uint64_t milliseconds) noexcept { value_ += milliseconds; }
void set(std::uint64_t milliseconds) noexcept { value_ = milliseconds; }
private:
std::uint64_t value_;
};

View File

@@ -0,0 +1,109 @@
#include <unity.h>
#include "CivilTime.h"
#include "SessionClock.h"
#include "FakeMonotonicClock.h"
using namespace timbre_core;
void setUp(void) {}
void tearDown(void) {}
static void test_fixed_utc_minus_six_conversion(void) {
// 2024-01-01 00:00 UTC is 2023-12-31 18:00 in the fixed local zone.
const CivilDateTime local = CivilTime::fromUnixSeconds(1704067200LL);
TEST_ASSERT_EQUAL_INT(2023, local.year);
TEST_ASSERT_EQUAL_INT(12, local.month);
TEST_ASSERT_EQUAL_INT(31, local.day);
TEST_ASSERT_EQUAL_INT(18, local.hour);
TEST_ASSERT_EQUAL_INT(0, local.minute);
TEST_ASSERT_EQUAL_INT(0, local.second);
TEST_ASSERT_EQUAL_INT(0, local.weekday); // Sunday
}
static void test_leap_day_and_round_trip(void) {
CivilDateTime leap;
leap.year = 2024;
leap.month = 2;
leap.day = 29;
leap.hour = 12;
leap.minute = 34;
leap.second = 56;
leap.weekday = CivilTime::weekdayForDate(leap.year, leap.month, leap.day);
TEST_ASSERT_TRUE(leap.valid());
std::int64_t epoch = 0;
TEST_ASSERT_TRUE(CivilTime::toUnixSeconds(leap, epoch));
const CivilDateTime roundTrip = CivilTime::fromUnixSeconds(epoch);
TEST_ASSERT_TRUE(roundTrip == leap);
TEST_ASSERT_TRUE(CivilTime::isLeapYear(2024));
TEST_ASSERT_FALSE(CivilTime::isLeapYear(1900));
TEST_ASSERT_TRUE(CivilTime::isLeapYear(2000));
TEST_ASSERT_EQUAL_INT(29, CivilTime::daysInMonth(2024, 2));
TEST_ASSERT_EQUAL_INT(28, CivilTime::daysInMonth(1900, 2));
}
static void test_pre_epoch_values_use_floor_seconds(void) {
const CivilDateTime local = CivilTime::fromUnixSeconds(-1);
TEST_ASSERT_EQUAL_INT(1969, local.year);
TEST_ASSERT_EQUAL_INT(12, local.month);
TEST_ASSERT_EQUAL_INT(31, local.day);
// UTC-1 is 17:59:59 in the fixed UTC-06:00 local zone.
TEST_ASSERT_EQUAL_INT(17, local.hour);
TEST_ASSERT_EQUAL_INT(59, local.minute);
TEST_ASSERT_EQUAL_INT(59, local.second);
const CivilDateTime invalid{CivilDateTime{2023, 2, 29, 0, 0, 0, 0}};
TEST_ASSERT_FALSE(invalid.valid());
}
static void test_date_and_minute_keys_are_stable(void) {
CivilDateTime value{2024, 1, 1, 8, 5, 0, 0};
const LocalMinuteKey first = LocalMinuteKey::from(value);
TEST_ASSERT_EQUAL_INT64(20240101LL, first.date);
TEST_ASSERT_EQUAL_INT(485, first.minuteOfDay);
// Keep the same local time so the serial difference is exactly one day.
CivilDateTime nextDay{2024, 1, 2, 8, 5, 0, 0};
const LocalMinuteKey second = LocalMinuteKey::from(nextDay);
TEST_ASSERT_TRUE(first < second);
TEST_ASSERT_TRUE(first != second);
TEST_ASSERT_EQUAL_INT64(1LL * 1440LL, second.serial() - first.serial());
}
static void test_session_clock_anchors_first_observation_without_catchup(void) {
FakeMonotonicClock source(100);
SessionClock clock(source);
const SessionClock::Observation first = clock.observe(1704067200LL);
TEST_ASSERT_TRUE(first.valid);
TEST_ASSERT_TRUE(first.firstObservation);
TEST_ASSERT_EQUAL_UINT64(100, first.monotonicMs);
source.advance(5000);
const SessionClock::Observation later = clock.observe(1704067205LL);
TEST_ASSERT_TRUE(later.valid);
TEST_ASSERT_FALSE(later.firstObservation);
TEST_ASSERT_FALSE(later.monotonicRegression);
TEST_ASSERT_EQUAL_INT(2023, later.local.year);
TEST_ASSERT_EQUAL_INT(18, later.local.hour);
TEST_ASSERT_EQUAL_INT(0, later.local.minute);
TEST_ASSERT_EQUAL_INT(5, later.local.second);
source.advance(1000);
const SessionClock::Observation tick = clock.tick();
TEST_ASSERT_TRUE(tick.valid);
TEST_ASSERT_EQUAL_INT(6, tick.local.second);
TEST_ASSERT_EQUAL_UINT64(6000, clock.sessionElapsedMs());
}
int main(int, char**) {
UNITY_BEGIN();
RUN_TEST(test_fixed_utc_minus_six_conversion);
RUN_TEST(test_leap_day_and_round_trip);
RUN_TEST(test_pre_epoch_values_use_floor_seconds);
RUN_TEST(test_date_and_minute_keys_are_stable);
RUN_TEST(test_session_clock_anchors_first_observation_without_catchup);
return UNITY_END();
}

View File

@@ -0,0 +1,153 @@
#include <unity.h>
#include <vector>
#include "PatternEngine.h"
using namespace timbre_core;
void setUp(void) {}
void tearDown(void) {}
struct OutputEvent {
RelayId relay;
bool on;
};
static void test_absolute_deadlines_and_final_off(void) {
std::vector<OutputEvent> events;
PatternEngine engine([&events](RelayId relay, bool on) {
events.push_back(OutputEvent{relay, on});
});
const Pattern pattern{PatternId::A, {2, 1, 1, 1}};
TEST_ASSERT_TRUE(engine.start(pattern, 1000));
TEST_ASSERT_TRUE(engine.running());
TEST_ASSERT_TRUE(engine.outputOn());
TEST_ASSERT_EQUAL_UINT64(3000, engine.deadlineMs());
TEST_ASSERT_EQUAL_size_t(1, events.size());
TEST_ASSERT_TRUE(events[0].on);
TEST_ASSERT_EQUAL_UINT8(static_cast<std::uint8_t>(RelayId::Main),
static_cast<std::uint8_t>(events[0].relay));
TEST_ASSERT_FALSE(engine.update(2999));
TEST_ASSERT_TRUE(engine.outputOn());
TEST_ASSERT_TRUE(engine.update(3000));
TEST_ASSERT_FALSE(engine.outputOn());
TEST_ASSERT_EQUAL_UINT8(1, engine.activePhase());
TEST_ASSERT_EQUAL_UINT64(4000, engine.deadlineMs());
TEST_ASSERT_TRUE(engine.update(5000));
// At 5000 ms the third phase has also reached its boundary; the
// absolute-deadline engine therefore advances to the final OFF phase.
TEST_ASSERT_FALSE(engine.outputOn());
TEST_ASSERT_EQUAL_UINT8(3, engine.activePhase());
TEST_ASSERT_EQUAL_UINT64(6000, engine.deadlineMs());
TEST_ASSERT_TRUE(engine.update(7000));
TEST_ASSERT_FALSE(engine.running());
TEST_ASSERT_FALSE(engine.outputOn());
TEST_ASSERT_EQUAL_UINT8(PatternEngine::kNoPhase, engine.activePhase());
TEST_ASSERT_EQUAL_size_t(4, events.size());
TEST_ASSERT_FALSE(events.back().on);
}
static void test_clock_regression_turns_output_off(void) {
std::vector<OutputEvent> events;
PatternEngine engine([&events](RelayId relay, bool on) {
events.push_back({relay, on});
});
const Pattern pattern{PatternId::A, {2, 1}};
TEST_ASSERT_TRUE(engine.start(pattern, 1000));
TEST_ASSERT_TRUE(engine.outputOn());
TEST_ASSERT_TRUE(engine.update(500));
TEST_ASSERT_FALSE(engine.running());
TEST_ASSERT_FALSE(engine.outputOn());
TEST_ASSERT_FALSE(events.empty());
TEST_ASSERT_FALSE(events.back().on);
}
static void test_late_poll_does_not_accumulate_or_leave_on(void) {
std::vector<OutputEvent> events;
PatternEngine engine([&events](RelayId, bool on) { events.push_back({RelayId::Main, on}); });
const Pattern pattern{PatternId::B, {1, 1, 1, 1, 1, 1}};
TEST_ASSERT_TRUE(engine.start(pattern, 10));
TEST_ASSERT_TRUE(engine.update(1000000));
TEST_ASSERT_FALSE(engine.running());
TEST_ASSERT_FALSE(engine.outputOn());
TEST_ASSERT_EQUAL_UINT64(0, engine.deadlineMs());
TEST_ASSERT_FALSE(events.empty());
TEST_ASSERT_FALSE(events.back().on);
}
static void test_fixed_queue_capacity_and_absolute_request_time(void) {
std::vector<OutputEvent> events;
PatternEngine engine([&events](RelayId relay, bool on) {
events.push_back({relay, on});
});
const Pattern pattern{PatternId::C, {1, 1}};
for (std::size_t i = 0; i < 8; ++i) {
TEST_ASSERT_TRUE(engine.enqueue(pattern, 1000));
}
TEST_ASSERT_EQUAL_size_t(8, engine.queueSize());
TEST_ASSERT_FALSE(engine.enqueue(pattern, 1000));
TEST_ASSERT_EQUAL_size_t(8, engine.queueSize());
TEST_ASSERT_FALSE(engine.running());
engine.update(999);
TEST_ASSERT_FALSE(engine.running());
TEST_ASSERT_EQUAL_size_t(8, engine.queueSize());
engine.update(1000);
TEST_ASSERT_TRUE(engine.running());
TEST_ASSERT_EQUAL_size_t(7, engine.queueSize());
TEST_ASSERT_TRUE(engine.outputOn());
TEST_ASSERT_EQUAL_UINT64(2000, engine.deadlineMs());
}
static void test_invalid_pattern_id_and_zero_phase_are_safe(void) {
std::vector<OutputEvent> events;
PatternEngine engine([&events](RelayId relay, bool on) {
events.push_back({relay, on});
});
TEST_ASSERT_FALSE(engine.start(static_cast<PatternId>(99), 0));
TEST_ASSERT_FALSE(engine.running());
TEST_ASSERT_TRUE(events.empty());
const Pattern silent{PatternId::A, {0, 0}};
TEST_ASSERT_TRUE(engine.start(silent, 0));
TEST_ASSERT_FALSE(engine.running());
TEST_ASSERT_FALSE(engine.outputOn());
TEST_ASSERT_EQUAL_size_t(0, events.size());
}
static void test_reserved_gpio_is_never_driven(void) {
std::vector<OutputEvent> events;
PatternEngine engine([&events](RelayId relay, bool on) {
events.push_back({relay, on});
});
const Pattern pattern{PatternId::A, {1, 1}};
TEST_ASSERT_TRUE(engine.start(pattern, 0));
TEST_ASSERT_TRUE(engine.update(2000));
TEST_ASSERT_FALSE(engine.reservedRelayDriven());
TEST_ASSERT_EQUAL_UINT8(static_cast<std::uint8_t>(RelayId::Main),
static_cast<std::uint8_t>(engine.lastOutputRelay()));
for (const OutputEvent& event : events) {
TEST_ASSERT_EQUAL_UINT8(static_cast<std::uint8_t>(RelayId::Main),
static_cast<std::uint8_t>(event.relay));
}
}
int main(int, char**) {
UNITY_BEGIN();
RUN_TEST(test_absolute_deadlines_and_final_off);
RUN_TEST(test_clock_regression_turns_output_off);
RUN_TEST(test_late_poll_does_not_accumulate_or_leave_on);
RUN_TEST(test_fixed_queue_capacity_and_absolute_request_time);
RUN_TEST(test_invalid_pattern_id_and_zero_phase_are_safe);
RUN_TEST(test_reserved_gpio_is_never_driven);
return UNITY_END();
}

View File

@@ -0,0 +1,15 @@
#pragma once
#include "SessionClock.h"
class FakeMonotonicClock final : public timbre_core::MonotonicClock {
public:
explicit FakeMonotonicClock(std::uint64_t initial = 0) : value_(initial) {}
std::uint64_t nowMs() const override { return value_; }
void advance(std::uint64_t milliseconds) noexcept { value_ += milliseconds; }
void set(std::uint64_t milliseconds) noexcept { value_ = milliseconds; }
private:
std::uint64_t value_;
};

View File

@@ -0,0 +1,136 @@
#include <unity.h>
#include "Scheduler.h"
#include "FakeMonotonicClock.h"
using namespace timbre_core;
void setUp(void) {}
void tearDown(void) {}
static AppState schedulerState() {
AppState state;
state.profiles.push_back(Profile{4, "Escuela"});
state.activeProfileId = 4;
state.patterns[0] = Pattern{PatternId::A, {2, 1, 2, 1, 2, 1}};
state.patterns[1] = Pattern{PatternId::B, {1, 1, 1, 1, 1, 1}};
state.patterns[2] = Pattern{PatternId::C, {1, 1, 1, 1, 1, 1}};
// 08:30 Monday through Friday. Bit 1 is Monday.
state.schedules.push_back(Schedule{20, 4, PatternId::A, 8 * 60 + 30, 0x3E, true});
return state;
}
static CivilDateTime mondayAt(std::uint8_t hour, std::uint8_t minute) {
CivilDateTime value{2024, 1, 1, hour, minute, 0, 0};
value.weekday = CivilTime::weekdayForDate(value.year, value.month, value.day);
return value;
}
static void test_first_observation_and_same_key_are_not_replayed(void) {
Scheduler scheduler;
const AppState state = schedulerState();
ScheduleDecision decision = scheduler.poll(state, mondayAt(8, 30), true);
TEST_ASSERT_FALSE(decision.due);
TEST_ASSERT_TRUE(decision.firstObservation);
decision = scheduler.poll(state, mondayAt(8, 30), false);
TEST_ASSERT_FALSE(decision.due);
TEST_ASSERT_TRUE(decision.alreadyProcessed);
decision = scheduler.poll(state, mondayAt(8, 30), false);
TEST_ASSERT_FALSE(decision.due);
TEST_ASSERT_TRUE(decision.alreadyProcessed);
}
static void test_current_minute_is_triggered_once(void) {
Scheduler scheduler;
const AppState state = schedulerState();
TEST_ASSERT_FALSE(scheduler.poll(state, mondayAt(8, 29), true).due);
const ScheduleDecision due = scheduler.poll(state, mondayAt(8, 30), false);
TEST_ASSERT_TRUE(due.due);
TEST_ASSERT_EQUAL_UINT16(20, due.scheduleId);
TEST_ASSERT_EQUAL_UINT8(static_cast<std::uint8_t>(PatternId::A),
static_cast<std::uint8_t>(due.patternId));
const ScheduleDecision duplicate = scheduler.poll(state, mondayAt(8, 30), false);
TEST_ASSERT_FALSE(duplicate.due);
TEST_ASSERT_TRUE(duplicate.alreadyProcessed);
}
static void test_gap_does_not_replay_intermediate_minutes(void) {
Scheduler scheduler;
AppState state = schedulerState();
// Only 08:31 is configured for this isolated test.
state.schedules.clear();
state.schedules.push_back(Schedule{21, 4, PatternId::C, 8 * 60 + 31, 0x3E, true});
TEST_ASSERT_FALSE(scheduler.poll(state, mondayAt(8, 30), true).due);
const ScheduleDecision skipped = scheduler.poll(state, mondayAt(8, 33), false);
TEST_ASSERT_FALSE(skipped.due);
TEST_ASSERT_EQUAL_UINT64(2, skipped.missedMinutes);
// A wall-clock rollback cannot make the old minute ring again.
const ScheduleDecision rollback = scheduler.poll(state, mondayAt(8, 31), false);
TEST_ASSERT_FALSE(rollback.due);
TEST_ASSERT_TRUE(rollback.alreadyProcessed);
}
static void test_weekday_profile_and_holiday_guards(void) {
Scheduler scheduler;
AppState state = schedulerState();
state.holidays.push_back(Holiday{1, 1, 1, true});
TEST_ASSERT_FALSE(scheduler.poll(state, mondayAt(8, 29), true).due);
const ScheduleDecision holiday = scheduler.poll(state, mondayAt(8, 30), false);
TEST_ASSERT_FALSE(holiday.due);
TEST_ASSERT_TRUE(holiday.blockedByHoliday);
state.holidays.clear();
state.activeProfileId = 99;
const ScheduleDecision wrongProfile = scheduler.poll(state, mondayAt(8, 31), false);
TEST_ASSERT_FALSE(wrongProfile.due);
state.activeProfileId = 4;
const CivilDateTime sunday{2024, 1, 7, 8, 30, 0, 0};
const ScheduleDecision wrongDay = scheduler.poll(state, sunday, false);
TEST_ASSERT_FALSE(wrongDay.due);
}
static void test_session_clock_integration_uses_first_observation_flag(void) {
Scheduler scheduler;
const AppState state = schedulerState();
FakeMonotonicClock source(10);
SessionClock clock(source);
const CivilDateTime firstLocal = mondayAt(8, 30);
const std::int64_t firstEpoch = CivilTime::toUnixSeconds(firstLocal);
const ScheduleDecision first = scheduler.poll(state, clock, firstEpoch);
TEST_ASSERT_FALSE(first.due);
TEST_ASSERT_TRUE(first.firstObservation);
source.advance(60000);
const ScheduleDecision due = scheduler.poll(
state, clock, CivilTime::toUnixSeconds(mondayAt(8, 31)));
TEST_ASSERT_FALSE(due.due); // configured at 08:30, not 08:31
source.advance(60000);
const CivilDateTime sameMinute = mondayAt(8, 30);
// The local key is already consumed; moving the fake wall source back
// must not replay it.
const ScheduleDecision replay = scheduler.poll(state, clock,
CivilTime::toUnixSeconds(sameMinute));
TEST_ASSERT_FALSE(replay.due);
TEST_ASSERT_TRUE(replay.alreadyProcessed);
}
int main(int, char**) {
UNITY_BEGIN();
RUN_TEST(test_first_observation_and_same_key_are_not_replayed);
RUN_TEST(test_current_minute_is_triggered_once);
RUN_TEST(test_gap_does_not_replay_intermediate_minutes);
RUN_TEST(test_weekday_profile_and_holiday_guards);
RUN_TEST(test_session_clock_integration_uses_first_observation_flag);
return UNITY_END();
}

View File

@@ -0,0 +1,165 @@
#include <unity.h>
#include "ScheduleValidator.h"
#include "TimbreService.h"
using namespace timbre_core;
void setUp(void) {}
void tearDown(void) {}
static AppState validState() {
AppState state;
state.profiles.push_back(Profile{7, "Principal"});
state.activeProfileId = 7;
state.patterns[0] = Pattern{PatternId::A, {1, 1, 1, 1, 1, 1}};
state.patterns[1] = Pattern{PatternId::B, {2, 1, 2, 1, 2, 1}};
state.patterns[2] = Pattern{PatternId::C, {1, 2, 1, 2, 1, 2}};
return state;
}
static void test_limits_and_canonical_patterns(void) {
TEST_ASSERT_EQUAL_size_t(8, kMaxProfiles);
TEST_ASSERT_EQUAL_size_t(3, kMaxPatterns);
TEST_ASSERT_EQUAL_size_t(100, kMaxSchedules);
TEST_ASSERT_EQUAL_size_t(64, kMaxHolidays);
TEST_ASSERT_EQUAL_size_t(8, kPatternQueueCapacity);
TEST_ASSERT_EQUAL_UINT8(99, kMaxPhaseDurationSeconds);
TEST_ASSERT_EQUAL_UINT16(600, kMaxPatternTotalSeconds);
TEST_ASSERT_TRUE(isPatternId(PatternId::A));
TEST_ASSERT_TRUE(isPatternId(PatternId::B));
TEST_ASSERT_TRUE(isPatternId(PatternId::C));
TEST_ASSERT_FALSE(isPatternId(static_cast<PatternId>(3)));
TEST_ASSERT_EQUAL_UINT8(22, gpioForRelay(RelayId::Main));
TEST_ASSERT_EQUAL_UINT8(23, gpioForRelay(RelayId::Reserved));
TEST_ASSERT_TRUE(isReservedRelay(RelayId::Reserved));
TEST_ASSERT_FALSE(isReservedRelay(RelayId::Main));
}
static void test_valid_state_and_phase_limits(void) {
AppState state = validState();
TEST_ASSERT_TRUE(ScheduleValidator::validate(state).valid);
state.patterns[0].phases[0] = 100;
ValidationResult result = ScheduleValidator::validate(state);
TEST_ASSERT_FALSE(result.valid);
TEST_ASSERT_EQUAL_UINT8(static_cast<std::uint8_t>(ValidationCode::InvalidPhaseDuration),
static_cast<std::uint8_t>(result.code));
state = validState();
state.patterns[1].phaseCount = kMaxPatternPhases + 1;
result = ScheduleValidator::validate(state);
TEST_ASSERT_FALSE(result.valid);
TEST_ASSERT_EQUAL_UINT8(static_cast<std::uint8_t>(ValidationCode::InvalidPhaseCount),
static_cast<std::uint8_t>(result.code));
state = validState();
state.patterns[2].phaseCount = 6;
for (std::size_t i = 0; i < 6; ++i) {
state.patterns[2].phases[i] = 99;
}
TEST_ASSERT_TRUE(ScheduleValidator::validate(state).valid);
}
static void test_schedule_references_and_stable_ids(void) {
AppState state = validState();
Schedule schedule{10, 7, PatternId::A, 8 * 60 + 30, 0x7F, true};
state.schedules.push_back(schedule);
TEST_ASSERT_TRUE(ScheduleValidator::validate(state).valid);
Schedule* found = state.schedule(10);
TEST_ASSERT_NOT_NULL(found);
TEST_ASSERT_EQUAL_UINT16(510, found->minuteOfDay);
state.schedules[0].profileId = 99;
ValidationResult result = ScheduleValidator::validate(state);
TEST_ASSERT_FALSE(result.valid);
TEST_ASSERT_EQUAL_UINT8(static_cast<std::uint8_t>(ValidationCode::UnknownProfile),
static_cast<std::uint8_t>(result.code));
state = validState();
state.profiles.push_back(Profile{7, "Duplicado"});
result = ScheduleValidator::validate(state);
TEST_ASSERT_FALSE(result.valid);
TEST_ASSERT_EQUAL_UINT8(static_cast<std::uint8_t>(ValidationCode::DuplicateProfileId),
static_cast<std::uint8_t>(result.code));
}
static void test_holiday_validation_and_capacity(void) {
AppState state = validState();
state.holidays.push_back(Holiday{1, 1, 1, true});
state.holidays.push_back(Holiday{1, 1, 2, true});
ValidationResult result = ScheduleValidator::validate(state);
TEST_ASSERT_FALSE(result.valid);
TEST_ASSERT_EQUAL_UINT8(static_cast<std::uint8_t>(ValidationCode::DuplicateHolidayId),
static_cast<std::uint8_t>(result.code));
state = validState();
for (std::size_t i = 0; i < 64; ++i) {
const std::uint8_t month = static_cast<std::uint8_t>((i % 12) + 1);
const std::uint8_t day = static_cast<std::uint8_t>((i / 12) + 1);
TEST_ASSERT_TRUE(state.holidays.push_back(
Holiday{static_cast<HolidayId>(i + 1), month, day, true}));
}
TEST_ASSERT_EQUAL_size_t(64, state.holidays.size());
TEST_ASSERT_TRUE(ScheduleValidator::validate(state).valid);
state.holidays[0].month = 0;
result = ScheduleValidator::validate(state);
TEST_ASSERT_FALSE(result.valid);
TEST_ASSERT_EQUAL_UINT8(static_cast<std::uint8_t>(ValidationCode::InvalidHolidayDate),
static_cast<std::uint8_t>(result.code));
}
static void test_schedule_and_profile_capacity(void) {
AppState state;
for (std::size_t i = 0; i < kMaxProfiles; ++i) {
TEST_ASSERT_TRUE(state.profiles.push_back(
Profile{static_cast<ProfileId>(i + 1), "P"}));
}
state.activeProfileId = 1;
for (std::size_t i = 0; i < kMaxSchedules; ++i) {
TEST_ASSERT_TRUE(state.schedules.push_back(
Schedule{static_cast<ScheduleId>(i + 1), 1, PatternId::A, 0, 0x7F, true}));
}
TEST_ASSERT_TRUE(ScheduleValidator::validate(state).valid);
TEST_ASSERT_EQUAL_size_t(kMaxProfiles, state.profiles.capacity());
TEST_ASSERT_EQUAL_size_t(kMaxSchedules, state.schedules.capacity());
}
static void test_transaction_publishes_only_after_commit(void) {
MemoryStateRepository repository;
AppState initial = validState();
repository.seed(initial);
TimbreService service(repository);
TEST_ASSERT_TRUE(service.load());
const std::size_t before = service.state().schedules.size();
repository.setCommitSucceeds(false);
TEST_ASSERT_FALSE(service.transact([](AppState& candidate) {
candidate.schedules.push_back(Schedule{44, 7, PatternId::B, 60, 0x7F, true});
}));
TEST_ASSERT_EQUAL_size_t(before, service.state().schedules.size());
TEST_ASSERT_EQUAL_UINT8(static_cast<std::uint8_t>(ServiceCode::CommitFailed),
static_cast<std::uint8_t>(service.lastError()));
repository.setCommitSucceeds(true);
TEST_ASSERT_TRUE(service.transact([](AppState& candidate) {
candidate.schedules.push_back(Schedule{44, 7, PatternId::B, 60, 0x7F, true});
}));
TEST_ASSERT_EQUAL_size_t(before + 1, service.state().schedules.size());
TEST_ASSERT_EQUAL_UINT16(before == 0 ? 1 : 0,
service.state().schedules.back().id == 44 ? 1 : 0);
}
int main(int, char**) {
UNITY_BEGIN();
RUN_TEST(test_limits_and_canonical_patterns);
RUN_TEST(test_valid_state_and_phase_limits);
RUN_TEST(test_schedule_references_and_stable_ids);
RUN_TEST(test_holiday_validation_and_capacity);
RUN_TEST(test_schedule_and_profile_capacity);
RUN_TEST(test_transaction_publishes_only_after_commit);
return UNITY_END();
}