feat: initialize Nocal terminal calendar
This commit is contained in:
234
tests/editor_tests.cpp
Normal file
234
tests/editor_tests.cpp
Normal file
@@ -0,0 +1,234 @@
|
||||
#include "nocal/domain/date.hpp"
|
||||
#include "nocal/tui/EventEditor.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
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");
|
||||
|
||||
(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("[ ] 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");
|
||||
}
|
||||
|
||||
} // 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();
|
||||
if (failures != 0) {
|
||||
std::cerr << failures << " editor test(s) failed\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
std::cout << "editor tests passed\n";
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
Reference in New Issue
Block a user