feat: recurrence rule editing in EventEditor and ordinal BYDAY support
Add recurrence_frequency, recurrence_interval, recurrence_count, and
recurrence_by_weekday editor fields. Frequency cycles with Space.
BYDAY input accepts comma-separated names and ordinal prefixes (1MO).
Extend RecurrenceRule::by_weekday from std::chrono::weekday to ByWeekday
{ordinal, day} struct. Parser accepts ordinal BYDAY (+1MO, -1FR).
Remove strict BY*/FREQ pairing constraint to match RFC 5545.
This commit is contained in:
@@ -315,6 +315,182 @@ void test_zoned_edit_preserves_series_and_rejects_dst_gap()
|
||||
"a nonexistent source-zone civil time in the DST gap is rejected");
|
||||
}
|
||||
|
||||
void test_recurrence_frequency_cycling()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
nocal::tui::EventEditor editor{nocal::Date{2026y / July / 17d}};
|
||||
(void)editor.handle_key("Weekly meeting");
|
||||
focus(editor, nocal::tui::EditorField::recurrence_frequency);
|
||||
|
||||
// Default is "none"
|
||||
check(editor.field_value(nocal::tui::EditorField::recurrence_frequency) == "none",
|
||||
"new event starts with no recurrence");
|
||||
|
||||
// Space cycles through frequencies
|
||||
(void)editor.handle_key(" ");
|
||||
check(editor.field_value(nocal::tui::EditorField::recurrence_frequency) == "daily",
|
||||
"Space cycles to daily");
|
||||
|
||||
(void)editor.handle_key(" ");
|
||||
check(editor.field_value(nocal::tui::EditorField::recurrence_frequency) == "weekly",
|
||||
"Space cycles to weekly");
|
||||
|
||||
(void)editor.handle_key(" ");
|
||||
check(editor.field_value(nocal::tui::EditorField::recurrence_frequency) == "monthly",
|
||||
"Space cycles to monthly");
|
||||
|
||||
(void)editor.handle_key(" ");
|
||||
check(editor.field_value(nocal::tui::EditorField::recurrence_frequency) == "yearly",
|
||||
"Space cycles to yearly");
|
||||
|
||||
(void)editor.handle_key(" ");
|
||||
check(editor.field_value(nocal::tui::EditorField::recurrence_frequency) == "none",
|
||||
"Space wraps around to none");
|
||||
}
|
||||
|
||||
void test_recurrence_submit()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
const nocal::Date day{2026y / July / 17d};
|
||||
|
||||
// Test: daily recurrence with count
|
||||
{
|
||||
nocal::tui::EventEditor editor{day};
|
||||
(void)editor.handle_key("Daily standup");
|
||||
focus(editor, nocal::tui::EditorField::recurrence_frequency);
|
||||
(void)editor.handle_key(" "); // -> daily
|
||||
focus(editor, nocal::tui::EditorField::recurrence_count);
|
||||
replace_field(editor, "5");
|
||||
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
|
||||
"daily with count=5 submits");
|
||||
check(editor.result().recurrence.has_value(), "recurrence should be set");
|
||||
check(editor.result().recurrence->frequency == nocal::RecurrenceFrequency::daily,
|
||||
"frequency is daily");
|
||||
check(editor.result().recurrence->interval == 1, "default interval is 1");
|
||||
check(editor.result().recurrence->count == 5, "count is 5");
|
||||
}
|
||||
|
||||
// Test: weekly recurrence (no by_weekday -> fills with DTSTART's weekday)
|
||||
{
|
||||
nocal::tui::EventEditor editor{day};
|
||||
(void)editor.handle_key("Weekly sync");
|
||||
focus(editor, nocal::tui::EditorField::recurrence_frequency);
|
||||
(void)editor.handle_key(" "); // -> daily
|
||||
(void)editor.handle_key(" "); // -> weekly
|
||||
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
|
||||
"weekly without BYDAY submits");
|
||||
check(editor.result().recurrence.has_value(), "recurrence should be set");
|
||||
check(editor.result().recurrence->frequency == nocal::RecurrenceFrequency::weekly,
|
||||
"frequency is weekly");
|
||||
check(!editor.result().recurrence->by_weekday.empty(),
|
||||
"weekly without BYDAY should auto-fill with start weekday");
|
||||
// July 17, 2026 is a Friday
|
||||
check(editor.result().recurrence->by_weekday[0].day == std::chrono::Friday,
|
||||
"auto-filled weekday should be Friday for 2026-07-17");
|
||||
}
|
||||
|
||||
// Test: no recurrence (none)
|
||||
{
|
||||
nocal::tui::EventEditor editor{day};
|
||||
(void)editor.handle_key("One-off meeting");
|
||||
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
|
||||
"event with frequency=none submits");
|
||||
check(!editor.result().recurrence.has_value(),
|
||||
"frequency=none should clear recurrence");
|
||||
}
|
||||
|
||||
// Test: invalid interval
|
||||
{
|
||||
nocal::tui::EventEditor editor{day};
|
||||
(void)editor.handle_key("Bad interval");
|
||||
focus(editor, nocal::tui::EditorField::recurrence_frequency);
|
||||
(void)editor.handle_key(" "); // -> daily
|
||||
focus(editor, nocal::tui::EditorField::recurrence_interval);
|
||||
replace_field(editor, "0");
|
||||
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
|
||||
editor.error().find("Interval") != std::string_view::npos,
|
||||
"interval 0 is rejected");
|
||||
}
|
||||
}
|
||||
|
||||
void test_edit_recurring_event_populates_fields()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
nocal::Event event;
|
||||
event.uid = "recurring-edit-test";
|
||||
event.title = "Original recurring";
|
||||
event.start = sys_days{year{2026}/July/17} + 9h;
|
||||
event.end = sys_days{year{2026}/July/17} + 10h;
|
||||
event.recurrence = nocal::RecurrenceRule{
|
||||
.frequency = nocal::RecurrenceFrequency::weekly,
|
||||
.interval = 2,
|
||||
.count = 10,
|
||||
.until = std::nullopt,
|
||||
.by_weekday = {{0, std::chrono::Monday}, {0, std::chrono::Wednesday}, {0, std::chrono::Friday}},
|
||||
.by_month_day = {},
|
||||
.by_month = {},
|
||||
};
|
||||
|
||||
nocal::tui::EventEditor editor{event};
|
||||
check(editor.field_value(nocal::tui::EditorField::recurrence_frequency) == "weekly",
|
||||
"edit populates recurrence frequency from existing rule");
|
||||
check(editor.field_value(nocal::tui::EditorField::recurrence_interval) == "2",
|
||||
"edit populates recurrence interval from existing rule");
|
||||
check(editor.field_value(nocal::tui::EditorField::recurrence_count) == "10",
|
||||
"edit populates recurrence count from existing rule");
|
||||
check(editor.field_value(nocal::tui::EditorField::recurrence_by_weekday) == "MO,WE,FR",
|
||||
"edit populates BYDAY from existing rule");
|
||||
}
|
||||
|
||||
void test_rendering_shows_recurrence()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
nocal::tui::EventEditor editor{nocal::Date{2026y / July / 17d}};
|
||||
const auto plain = editor.render(72, 22, false);
|
||||
check(plain.find("Repeat") != std::string::npos,
|
||||
"render shows Recurrence label");
|
||||
check(plain.find("none") != std::string::npos,
|
||||
"render shows default frequency as none");
|
||||
|
||||
// Cycle to weekly and check render
|
||||
focus(editor, nocal::tui::EditorField::recurrence_frequency);
|
||||
(void)editor.handle_key(" "); // -> daily
|
||||
(void)editor.handle_key(" "); // -> weekly
|
||||
const auto with_recurrence = editor.render(72, 22, false);
|
||||
check(with_recurrence.find("weekly") != std::string::npos,
|
||||
"render shows weekly when set");
|
||||
check(with_recurrence.find("Interval") != std::string::npos,
|
||||
"render shows Interval when frequency is set");
|
||||
check(with_recurrence.find("Count") != std::string::npos,
|
||||
"render shows Count when frequency is set");
|
||||
check(with_recurrence.find("On days") != std::string::npos,
|
||||
"render shows On days when frequency is set");
|
||||
}
|
||||
|
||||
void test_recurrence_weekly_by_weekday_input()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
const nocal::Date day{2026y / July / 17d};
|
||||
nocal::tui::EventEditor editor{day};
|
||||
(void)editor.handle_key("Custom weekly");
|
||||
focus(editor, nocal::tui::EditorField::recurrence_frequency);
|
||||
(void)editor.handle_key(" "); // -> daily
|
||||
(void)editor.handle_key(" "); // -> weekly
|
||||
focus(editor, nocal::tui::EditorField::recurrence_by_weekday);
|
||||
replace_field(editor, "MO,WE,FR");
|
||||
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
|
||||
"custom weekly with MO,WE,FR submits");
|
||||
check(editor.result().recurrence.has_value(), "recurrence should be set");
|
||||
check(editor.result().recurrence->by_weekday.size() == 3,
|
||||
"three BYDAY entries expected");
|
||||
check(editor.result().recurrence->by_weekday[0].day == std::chrono::Monday,
|
||||
"first BYDAY is Monday");
|
||||
check(editor.result().recurrence->by_weekday[1].day == std::chrono::Wednesday,
|
||||
"second BYDAY is Wednesday");
|
||||
check(editor.result().recurrence->by_weekday[2].day == std::chrono::Friday,
|
||||
"third BYDAY is Friday");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main()
|
||||
@@ -326,6 +502,11 @@ int main()
|
||||
test_unicode_backspace_navigation_and_cancel();
|
||||
test_rendering();
|
||||
test_zoned_edit_preserves_series_and_rejects_dst_gap();
|
||||
test_recurrence_frequency_cycling();
|
||||
test_recurrence_submit();
|
||||
test_edit_recurring_event_populates_fields();
|
||||
test_rendering_shows_recurrence();
|
||||
test_recurrence_weekly_by_weekday_input();
|
||||
if (failures != 0) {
|
||||
std::cerr << failures << " editor test(s) failed\n";
|
||||
return EXIT_FAILURE;
|
||||
|
||||
Reference in New Issue
Block a user