Files
nocal/tests/editor_tests.cpp
Bernardo Magri 6d9e7b0e3e feat(tui): redesign chrome with unified key hints and help overlay
Introduce one key-hint grammar across every view: bold key, dim label,
priority-ordered hints that drop from the end instead of ellipsizing.
The month footer becomes a single split status bar with a friendly
selected date, the '?' mega-line becomes a real keyboard shortcut
panel, and the editor, agenda, search, picker, detail, and delete
frames share the same chips. Notifications carry check/warning marks.
Adds a live PTY smoke test covering view interaction and terminal
state restoration on q and Ctrl-C exits.
2026-07-20 22:51:28 +01:00

336 lines
15 KiB
C++

#include "nocal/domain/date.hpp"
#include "nocal/tui/EventEditor.hpp"
#include <algorithm>
#include <chrono>
#include <cstdlib>
#include <iostream>
#include <string>
#include <string_view>
#include <vector>
namespace {
int failures = 0;
void check(const bool condition, const std::string_view message)
{
if (!condition) {
++failures;
std::cerr << "FAIL: " << message << '\n';
}
}
void replace_field(nocal::tui::EventEditor& editor, const std::string_view value)
{
while (!editor.field_value(editor.focused_field()).empty()) {
(void)editor.handle_key("\x7f");
}
(void)editor.handle_key(value);
}
void focus(nocal::tui::EventEditor& editor, const nocal::tui::EditorField target)
{
while (editor.focused_field() != target) (void)editor.handle_key("\t");
}
void test_create_and_validation()
{
using namespace std::chrono;
const nocal::Date day{2026y / July / 17d};
nocal::tui::EventEditor editor{day};
check(editor.field_value(nocal::tui::EditorField::start_date) == "2026-07-17",
"new editor starts on selected date");
check(editor.field_value(nocal::tui::EditorField::start_time) == "09:00" &&
editor.field_value(nocal::tui::EditorField::end_time) == "10:00",
"new editor supplies useful default times");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
editor.focused_field() == nocal::tui::EditorField::title && !editor.error().empty(),
"blank title blocks submission and focuses the invalid field");
check(editor.render(72, 18, true).find("\x1b[1;31m") != std::string::npos,
"validation errors render in the error style");
check(editor.render(72, 18, false).find("\x1b[") == std::string::npos,
"error frame stays plain with ANSI disabled");
(void)editor.handle_key("Design review");
focus(editor, nocal::tui::EditorField::end_time);
replace_field(editor, "08:00");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
editor.error().find("after") != std::string_view::npos,
"backwards timed interval is rejected");
replace_field(editor, "10:30");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
"valid timed event submits");
check(editor.result().title == "Design review" && !editor.result().uid.empty(),
"submitted event contains title and generated UID");
check(editor.result().end > editor.result().start && !editor.result().all_day,
"submitted timed event has a strict interval");
nocal::tui::EventEditor second{day};
check(second.result().uid != editor.result().uid, "locally generated UIDs do not collide");
}
void test_invalid_civil_inputs()
{
using namespace std::chrono;
nocal::tui::EventEditor editor{nocal::Date{2026y / July / 17d}};
(void)editor.handle_key("Planning");
focus(editor, nocal::tui::EditorField::start_date);
replace_field(editor, "2026-02-30");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
editor.focused_field() == nocal::tui::EditorField::start_date,
"invalid civil date is rejected");
replace_field(editor, "2026-07-17");
focus(editor, nocal::tui::EditorField::start_time);
replace_field(editor, "25:10");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
editor.focused_field() == nocal::tui::EditorField::start_time,
"out-of-range clock time is rejected");
check(editor.render(64, 18, false).find("Start time must be") != std::string::npos,
"validation error is visible in the rendered form");
}
void test_edit_preserves_identity_and_metadata()
{
using namespace std::chrono;
const nocal::Date day{2026y / July / 17d};
nocal::Event existing;
existing.uid = "stable-id";
existing.title = "Old title";
existing.start = nocal::make_local_time(day, 14, 0);
existing.end = nocal::make_local_time(day, 15, 0);
existing.location = "Studio";
existing.description = "Original notes";
existing.calendar_id = "work";
existing.color = "blue";
nocal::tui::EventEditor editor{existing};
replace_field(editor, "New title");
focus(editor, nocal::tui::EditorField::notes);
check(editor.field_value(nocal::tui::EditorField::notes) == "Original notes",
"edit initializes notes from description");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
"edited event submits");
check(editor.result().uid == "stable-id" && editor.result().calendar_id == "work" &&
editor.result().color == "blue" && editor.result().description == "Original notes",
"editing preserves identity, calendar, color, and unchanged description");
check(editor.result().title == "New title", "editing updates changed fields");
}
void test_all_day_exclusive_end()
{
using namespace std::chrono;
const nocal::Date day{2026y / July / 17d};
nocal::tui::EventEditor editor{day};
(void)editor.handle_key("Conference");
focus(editor, nocal::tui::EditorField::all_day);
(void)editor.handle_key(" ");
check(editor.all_day(), "Space toggles all-day on its control");
check(editor.field_value(nocal::tui::EditorField::end_date) == "2026-07-18",
"all-day toggle supplies the normal exclusive next-day end");
focus(editor, nocal::tui::EditorField::end_date);
replace_field(editor, "2026-07-17");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
editor.focused_field() == nocal::tui::EditorField::end_date,
"same-day exclusive all-day end is rejected");
replace_field(editor, "2026-07-18");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
"next-day exclusive all-day end submits");
check(editor.result().all_day && nocal::local_date(editor.result().start) == day &&
nocal::local_date(editor.result().end) == nocal::Date{2026y / July / 18d},
"all-day result uses exclusive civil-day bounds");
}
void test_unicode_backspace_navigation_and_cancel()
{
using namespace std::chrono;
nocal::tui::EventEditor editor{nocal::Date{2026y / July / 17d}};
(void)editor.handle_key("Café ☕");
(void)editor.handle_key("\x7f");
check(editor.field_value(nocal::tui::EditorField::title) == "Café ",
"Backspace removes one complete UTF-8 code point");
(void)editor.handle_key("\x1b[B");
check(editor.focused_field() == nocal::tui::EditorField::start_date,
"Down arrow advances field focus");
(void)editor.handle_key("\x1b[A");
check(editor.focused_field() == nocal::tui::EditorField::title,
"Up arrow reverses field focus");
check(editor.handle_key("\x1b") == nocal::tui::EditorOutcome::cancel,
"Escape cancels without submission");
}
std::string strip_ansi(const std::string_view input)
{
std::string output;
for (std::size_t index = 0; index < input.size();) {
if (input[index] == '\x1b' && index + 1 < input.size() && input[index + 1] == '[') {
index += 2;
while (index < input.size() && input[index] != 'm') ++index;
if (index < input.size()) ++index;
} else {
output += input[index++];
}
}
return output;
}
void test_rendering()
{
using namespace std::chrono;
nocal::tui::EventEditor editor{nocal::Date{2026y / July / 17d}};
const auto plain = editor.render(72, 18, false);
check(std::count(plain.begin(), plain.end(), '\n') == 17,
"render fills requested height exactly");
check(plain.find("NEW APPOINTMENT") != std::string::npos &&
plain.find("Ctrl-S save") != std::string::npos &&
plain.find("Tab next field") != std::string::npos &&
plain.find("Esc cancel") != std::string::npos &&
plain.find("[ ] timed appointment") != std::string::npos,
"render exposes form identity and controls");
check(plain.find("\x1b[") == std::string::npos, "ANSI can be disabled");
const auto styled = editor.render(72, 18, true);
check(styled.find("\x1b[") != std::string::npos && strip_ansi(styled) == plain,
"semantic ANSI attributes do not alter frame geometry");
const auto compact = editor.render(24, 6, false);
check(std::count(compact.begin(), compact.end(), '\n') == 5,
"compact render also fills requested height");
(void)editor.handle_key("会議 ☕");
const auto wide = editor.render(40, 18, false);
std::size_t start = 0;
bool exact_width = true;
while (start <= wide.size()) {
const auto end = wide.find('\n', start);
const auto line = wide.substr(start, end == std::string::npos ? wide.size() - start
: end - start);
int cells = 0;
for (std::size_t at = 0; at < line.size();) {
const auto first = static_cast<unsigned char>(line[at]);
std::size_t length = 1;
if ((first & 0xe0U) == 0xc0U) length = 2;
else if ((first & 0xf0U) == 0xe0U) length = 3;
else if ((first & 0xf8U) == 0xf0U) length = 4;
const auto fragment = std::string_view{line}.substr(at, length);
if (fragment == "" || fragment == "" || fragment == "") cells += 2;
else ++cells;
at += length;
}
exact_width = exact_width && cells == 40;
if (end == std::string::npos) break;
start = end + 1;
}
check(exact_width, "wide Unicode input retains exact terminal-cell width");
}
nocal::TimePoint zoned_time(const std::string_view zone_name, const nocal::Date date,
const unsigned hour, const unsigned minute)
{
const auto* zone = std::chrono::locate_zone(zone_name);
const auto local = std::chrono::local_days{date} + std::chrono::hours{hour} +
std::chrono::minutes{minute};
return zone->to_sys(local, std::chrono::choose::earliest);
}
void test_zoned_edit_preserves_series_and_rejects_dst_gap()
{
using namespace std::chrono;
nocal::Event event;
event.uid = "zoned-series";
event.title = "New York standup";
event.time_basis = nocal::TimeBasis::zoned;
event.time_zone = "America/New_York";
event.start = zoned_time(event.time_zone, nocal::Date{2026y / March / 7d}, 2, 30);
event.end = zoned_time(event.time_zone, nocal::Date{2026y / March / 7d}, 3, 30);
event.recurrence = nocal::RecurrenceRule{
.frequency = nocal::RecurrenceFrequency::daily,
.interval = 1,
.count = 4,
.until = std::nullopt,
.by_weekday = {},
.by_month_day = {},
.by_month = {},
};
event.recurrence_exceptions.push_back(
zoned_time(event.time_zone, nocal::Date{2026y / March / 9d}, 2, 30));
event.recurrence_additions.push_back(
zoned_time(event.time_zone, nocal::Date{2026y / March / 12d}, 2, 30));
nocal::tui::EventEditor editor{event};
check(editor.field_value(nocal::tui::EditorField::start_date) == "2026-03-07" &&
editor.field_value(nocal::tui::EditorField::start_time) == "02:30",
"a zoned editor displays civil values in the event source timezone");
const auto form = editor.render(80, 18, false);
check(form.find("America/New_York") != std::string::npos,
"a zoned editor explicitly labels its source timezone");
check(form.find("EDIT ENTIRE RECURRING SERIES") != std::string::npos,
"the editor clearly states that edits affect the recurring series");
replace_field(editor, "Renamed standup");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
"a valid zoned series edit submits");
check(editor.result().time_basis == nocal::TimeBasis::zoned &&
editor.result().time_zone == "America/New_York" &&
editor.result().recurrence == event.recurrence &&
editor.result().recurrence_additions == event.recurrence_additions &&
editor.result().recurrence_exceptions == event.recurrence_exceptions,
"editing preserves time basis, TZID, recurrence additions, and exceptions");
nocal::tui::EventEditor series_kind_editor{event};
focus(series_kind_editor, nocal::tui::EditorField::all_day);
(void)series_kind_editor.handle_key(" ");
check(series_kind_editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
series_kind_editor.focused_field() == nocal::tui::EditorField::all_day &&
series_kind_editor.error().find("recurring series") != std::string_view::npos,
"a recurring series rejects timed/all-day conversion to preserve its exceptions");
auto rdate_only = event;
rdate_only.recurrence.reset();
nocal::tui::EventEditor rdate_editor{rdate_only};
check(rdate_editor.render(80, 18, false).find("EDIT ENTIRE RECURRING SERIES")
!= std::string::npos,
"an RDATE-only event is presented as a recurring series");
focus(rdate_editor, nocal::tui::EditorField::all_day);
(void)rdate_editor.handle_key(" ");
check(rdate_editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
rdate_editor.error().find("recurring series") != std::string_view::npos,
"an RDATE-only series rejects timed/all-day conversion");
auto one_off = event;
one_off.recurrence.reset();
one_off.recurrence_additions.clear();
one_off.recurrence_exceptions.clear();
nocal::tui::EventEditor all_day_editor{one_off};
focus(all_day_editor, nocal::tui::EditorField::all_day);
(void)all_day_editor.handle_key(" ");
check(all_day_editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit &&
all_day_editor.result().all_day &&
all_day_editor.result().time_basis == nocal::TimeBasis::floating &&
all_day_editor.result().time_zone.empty(),
"a one-off zoned event converted to all-day normalizes to floating DATE semantics");
nocal::tui::EventEditor gap_editor{event};
focus(gap_editor, nocal::tui::EditorField::start_date);
replace_field(gap_editor, "2026-03-08");
check(gap_editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
gap_editor.error().find("cannot be represented") != std::string_view::npos,
"a nonexistent source-zone civil time in the DST gap is rejected");
}
} // namespace
int main()
{
test_create_and_validation();
test_invalid_civil_inputs();
test_edit_preserves_identity_and_metadata();
test_all_day_exclusive_end();
test_unicode_backspace_navigation_and_cancel();
test_rendering();
test_zoned_edit_preserves_series_and_rejects_dst_gap();
if (failures != 0) {
std::cerr << failures << " editor test(s) failed\n";
return EXIT_FAILURE;
}
std::cout << "editor tests passed\n";
return EXIT_SUCCESS;
}