feat: make month week start configurable
Add a Monday-default --week-start option for every weekday and carry it through domain layout, full and compact rendering, event query windows, print mode, and the interactive TUI. Preserve byte-compatible Monday output and cover rotated headers, spill dates, invalid input, sanitizer behavior, and live terminal restoration.
This commit is contained in:
98
tests/cli_week_start_test.sh
Normal file
98
tests/cli_week_start_test.sh
Normal file
@@ -0,0 +1,98 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "$#" -ne 1 ]; then
|
||||
echo "usage: cli_week_start_test.sh /path/to/nocal" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
nocal=$1
|
||||
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/nocal-cli-week-start.XXXXXX")
|
||||
trap 'rm -rf "$tmpdir"' EXIT HUP INT TERM
|
||||
|
||||
fail()
|
||||
{
|
||||
echo "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_failure()
|
||||
{
|
||||
description=$1
|
||||
shift
|
||||
if "$@" >"$tmpdir/out" 2>"$tmpdir/err"; then
|
||||
fail "$description unexpectedly succeeded"
|
||||
fi
|
||||
}
|
||||
|
||||
help=$("$nocal" --help)
|
||||
case $help in
|
||||
*"--week-start DAY"*"monday through sunday"*) ;;
|
||||
*) fail "help does not document --week-start and its accepted range" ;;
|
||||
esac
|
||||
|
||||
"$nocal" --print --demo >"$tmpdir/default"
|
||||
"$nocal" --print --demo --week-start monday >"$tmpdir/monday"
|
||||
cmp "$tmpdir/default" "$tmpdir/monday" >/dev/null ||
|
||||
fail "default frame is not Monday-first"
|
||||
|
||||
header=$(sed -n '2p' "$tmpdir/monday" | tr -d ' ')
|
||||
case $header in
|
||||
*MondayTuesdayWednesdayThursdayFridaySaturdaySunday*) ;;
|
||||
*) fail "Monday-first header is not in weekday order" ;;
|
||||
esac
|
||||
|
||||
"$nocal" --print --demo --week-start sunday >"$tmpdir/sunday"
|
||||
header=$(sed -n '2p' "$tmpdir/sunday" | tr -d ' ')
|
||||
case $header in
|
||||
*SundayMondayTuesdayWednesdayThursdayFridaySaturday*) ;;
|
||||
*) fail "Sunday-first header was not rotated" ;;
|
||||
esac
|
||||
|
||||
"$nocal" --print --demo --week-start wednesday >"$tmpdir/wednesday"
|
||||
header=$(sed -n '2p' "$tmpdir/wednesday" | tr -d ' ')
|
||||
case $header in
|
||||
*WednesdayThursdayFridaySaturdaySundayMondayTuesday*) ;;
|
||||
*) fail "Wednesday-first header was not rotated" ;;
|
||||
esac
|
||||
|
||||
# The first numbered cell must be the correct spill date for each selected
|
||||
# week start. Nocal is Linux-only, so GNU date is available in its test setup.
|
||||
month_start=$(date +%Y-%m-01)
|
||||
month_weekday=$(date -d "$month_start" +%w)
|
||||
for selection in sunday:0 wednesday:3; do
|
||||
name=${selection%%:*}
|
||||
weekday=${selection##*:}
|
||||
offset=$(( (month_weekday - weekday + 7) % 7 ))
|
||||
expected=$(date -d "$month_start -$offset days" +%-d)
|
||||
first_row=$(sed -n '4p' "$tmpdir/$name")
|
||||
actual=$(printf '%s\n' "$first_row" | sed 's/[^0-9]/ /g' | awk '{print $1}')
|
||||
[ "$actual" = "$expected" ] ||
|
||||
fail "$name-first grid began on $actual instead of spill date $expected"
|
||||
done
|
||||
|
||||
# Option parsing finishes before the calendar is loaded or any write mode can
|
||||
# run. Invalid, missing, and repeated values therefore leave the target exact.
|
||||
calendar=$tmpdir/calendar.ics
|
||||
printf '%s\n' 'sentinel calendar bytes' >"$calendar"
|
||||
cp "$calendar" "$tmpdir/calendar-before"
|
||||
|
||||
expect_failure "invalid week-start value" \
|
||||
"$nocal" --calendar "$calendar" --week-start MONDAY
|
||||
grep -F -- "invalid --week-start day" "$tmpdir/err" >/dev/null ||
|
||||
fail "invalid week-start value did not report its parse error"
|
||||
|
||||
expect_failure "missing week-start value" \
|
||||
"$nocal" --calendar "$calendar" --week-start
|
||||
grep -F -- "--week-start requires a day" "$tmpdir/err" >/dev/null ||
|
||||
fail "missing week-start value did not report its parse error"
|
||||
|
||||
expect_failure "repeated week-start option" \
|
||||
"$nocal" --calendar "$calendar" --week-start sunday --week-start monday
|
||||
grep -F -- "--week-start may only be supplied once" "$tmpdir/err" >/dev/null ||
|
||||
fail "repeated week-start did not report its parse error"
|
||||
|
||||
cmp "$calendar" "$tmpdir/calendar-before" >/dev/null ||
|
||||
fail "week-start parse failure changed the calendar"
|
||||
[ ! -e "$calendar.bak" ] ||
|
||||
fail "week-start parse failure created a calendar backup"
|
||||
@@ -48,8 +48,27 @@ void test_dates()
|
||||
"Monday has index zero");
|
||||
check(nocal::monday_first_weekday(nocal::Date{2026y / July / 12d}) == 6,
|
||||
"Sunday has index six");
|
||||
const nocal::Date wednesday{2026y / July / 1d};
|
||||
for (unsigned start = 0; start < 7; ++start) {
|
||||
const weekday week_start{start};
|
||||
check(nocal::weekday_index(wednesday, week_start) ==
|
||||
(Wednesday.c_encoding() + 7U - start) % 7U,
|
||||
"weekday index is relative to each possible week start");
|
||||
}
|
||||
check(nocal::weekday_index(wednesday, Monday) ==
|
||||
nocal::monday_first_weekday(wednesday),
|
||||
"Monday weekday wrapper preserves configurable-index behavior");
|
||||
check_throws([] { (void)nocal::parse_date("2023-02-29"); },
|
||||
"invalid date is rejected");
|
||||
check_throws(
|
||||
[] {
|
||||
(void)nocal::weekday_index(
|
||||
nocal::Date{2023y / February / 29d}, Monday);
|
||||
},
|
||||
"weekday index rejects an invalid date");
|
||||
check_throws(
|
||||
[wednesday] { (void)nocal::weekday_index(wednesday, weekday{8}); },
|
||||
"weekday index rejects an invalid week start");
|
||||
}
|
||||
|
||||
void test_month_layout()
|
||||
@@ -57,6 +76,8 @@ void test_month_layout()
|
||||
using namespace std::chrono;
|
||||
const auto layout = nocal::make_month_layout(2026y / July);
|
||||
check(layout.cells.size() == 42, "month layout always has 42 cells");
|
||||
check(layout.week_start == Monday,
|
||||
"default month layout remains Monday-first");
|
||||
check(layout.first_visible_date() == nocal::Date{2026y / June / 29d},
|
||||
"July 2026 begins after Monday-leading spill cells");
|
||||
check(layout.last_visible_date() == nocal::Date{2026y / August / 9d},
|
||||
@@ -68,6 +89,50 @@ void test_month_layout()
|
||||
const auto* july_17 = layout.find(nocal::Date{2026y / July / 17d});
|
||||
check(july_17 != nullptr && july_17->row == 2 && july_17->column == 4,
|
||||
"date lookup finds the correct Friday cell");
|
||||
|
||||
const auto sunday_layout = nocal::make_month_layout(2026y / July, Sunday);
|
||||
check(sunday_layout.week_start == Sunday,
|
||||
"month layout records its configured week start");
|
||||
check(sunday_layout.first_visible_date() ==
|
||||
nocal::Date{2026y / June / 28d} &&
|
||||
sunday_layout.last_visible_date() ==
|
||||
nocal::Date{2026y / August / 8d},
|
||||
"Sunday-first July 2026 has the expected spill dates");
|
||||
const auto* sunday_july_first =
|
||||
sunday_layout.find(nocal::Date{2026y / July / 1d});
|
||||
check(sunday_july_first != nullptr && sunday_july_first->row == 0 &&
|
||||
sunday_july_first->column == 3,
|
||||
"Sunday-first July 2026 places Wednesday in column three");
|
||||
|
||||
for (unsigned start = 0; start < 7; ++start) {
|
||||
const weekday week_start{start};
|
||||
const auto configured =
|
||||
nocal::make_month_layout(2026y / July, week_start);
|
||||
check(configured.week_start == week_start,
|
||||
"each configured week start is retained by the layout");
|
||||
check(weekday{nocal::to_sys_days(configured.first_visible_date())} ==
|
||||
week_start,
|
||||
"each layout starts on its configured weekday");
|
||||
for (std::size_t index = 1; index < configured.cells.size(); ++index) {
|
||||
check(nocal::to_sys_days(configured.cells[index].date) ==
|
||||
nocal::to_sys_days(configured.cells[index - 1].date) +
|
||||
days{1},
|
||||
"month layout cells are 42 consecutive dates");
|
||||
}
|
||||
const auto* july_first =
|
||||
configured.find(nocal::Date{2026y / July / 1d});
|
||||
check(july_first != nullptr && july_first->row == 0 &&
|
||||
july_first->column ==
|
||||
nocal::weekday_index(nocal::Date{2026y / July / 1d},
|
||||
week_start),
|
||||
"July 2026 column follows the configured week start");
|
||||
}
|
||||
|
||||
check_throws(
|
||||
[] {
|
||||
(void)nocal::make_month_layout(2026y / July, weekday{8});
|
||||
},
|
||||
"month layout rejects an invalid week start");
|
||||
check_throws([&] { (void)layout.at(6, 0); }, "out-of-grid access is rejected");
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
#include <iostream>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
@@ -22,6 +24,43 @@ void check(bool condition, const char* message)
|
||||
}
|
||||
}
|
||||
|
||||
std::string_view frame_line(const std::string& frame, std::size_t requested)
|
||||
{
|
||||
std::size_t start = 0;
|
||||
for (std::size_t line = 0; line < requested; ++line) {
|
||||
const auto newline = frame.find('\n', start);
|
||||
if (newline == std::string::npos) return {};
|
||||
start = newline + 1;
|
||||
}
|
||||
const auto newline = frame.find('\n', start);
|
||||
return std::string_view{frame}.substr(
|
||||
start, newline == std::string::npos ? frame.size() - start : newline - start);
|
||||
}
|
||||
|
||||
bool ordered(std::string_view line, const std::vector<std::string_view>& labels)
|
||||
{
|
||||
std::size_t position = 0;
|
||||
for (const auto label : labels) {
|
||||
position = line.find(label, position);
|
||||
if (position == std::string_view::npos) return false;
|
||||
position += label.size();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
nocal::Event all_day_event(std::string uid, std::string title,
|
||||
nocal::Date start, nocal::Date exclusive_end)
|
||||
{
|
||||
nocal::Event event;
|
||||
event.uid = std::move(uid);
|
||||
event.title = std::move(title);
|
||||
event.start = nocal::make_local_time(start);
|
||||
event.end = nocal::make_local_time(exclusive_end);
|
||||
event.all_day = true;
|
||||
event.time_basis = nocal::TimeBasis::floating;
|
||||
return event;
|
||||
}
|
||||
|
||||
void test_full_month_render()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
@@ -49,6 +88,7 @@ void test_full_month_render()
|
||||
.agenda_start_day = year{1970} / January / day{1},
|
||||
.agenda_items = {},
|
||||
.agenda_index = 0,
|
||||
.week_start = Monday,
|
||||
};
|
||||
const std::vector<nocal::tui::CalendarItem> items{
|
||||
{.id = "1", .title = "Design review", .day = year{2026} / July / day{17},
|
||||
@@ -105,6 +145,7 @@ void test_compact_and_input()
|
||||
.agenda_start_day = year{1970} / January / day{1},
|
||||
.agenda_items = {},
|
||||
.agenda_index = 0,
|
||||
.week_start = Monday,
|
||||
};
|
||||
const auto frame = nocal::tui::render_month(std::span<const nocal::tui::CalendarItem>{}, state);
|
||||
check(frame.find("July 2026") != std::string::npos, "compact mode keeps month context");
|
||||
@@ -115,6 +156,109 @@ void test_compact_and_input()
|
||||
"c opens the calendar picker");
|
||||
}
|
||||
|
||||
void test_sunday_start_full_render_and_domain_window()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
auto state = nocal::tui::ScreenState{};
|
||||
state.visible_month = year{2026} / July;
|
||||
state.selected_day = year{2026} / July / day{1};
|
||||
state.today = year{2026} / July / day{17};
|
||||
state.width = 140;
|
||||
state.height = 34;
|
||||
state.ansi = false;
|
||||
state.week_start = Sunday;
|
||||
|
||||
const std::vector<nocal::Event> events{
|
||||
all_day_event("leading", "Leading Sunday", year{2026} / June / day{28},
|
||||
year{2026} / June / day{29}),
|
||||
all_day_event("trailing", "Trailing Saturday", year{2026} / August / day{8},
|
||||
year{2026} / August / day{9}),
|
||||
all_day_event("outside", "Outside Saturday", year{2026} / June / day{27},
|
||||
year{2026} / June / day{28}),
|
||||
};
|
||||
|
||||
const auto frame = nocal::tui::render_month(events, state);
|
||||
const auto header = frame_line(frame, 1);
|
||||
check(ordered(header, {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday",
|
||||
"Friday", "Saturday"}),
|
||||
"Sunday start rotates the full weekday header");
|
||||
check(frame_line(frame, 3).starts_with("│ 28"),
|
||||
"Sunday start places the June 28 spill day in the first cell");
|
||||
check(frame.find("Leading Sunday") != std::string::npos &&
|
||||
frame.find("Trailing") != std::string::npos,
|
||||
"domain adapter includes events at both ends of the configured 42-day grid");
|
||||
check(frame.find("Outside Saturday") == std::string::npos,
|
||||
"domain adapter excludes an event before the configured grid");
|
||||
check(frame.find("\x1b[") == std::string::npos,
|
||||
"Sunday-start full rendering supports ANSI-off output");
|
||||
check(std::count(frame.begin(), frame.end(), '\n') == 33,
|
||||
"Sunday-start full rendering preserves exact requested height");
|
||||
}
|
||||
|
||||
void test_wednesday_start_compact_render()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
auto state = nocal::tui::ScreenState{};
|
||||
state.visible_month = year{2026} / July;
|
||||
state.selected_day = year{2026} / July / day{1};
|
||||
state.today = year{2026} / July / day{17};
|
||||
state.width = 30;
|
||||
state.height = 12;
|
||||
state.ansi = false;
|
||||
state.week_start = Wednesday;
|
||||
const std::vector<nocal::tui::CalendarItem> items{
|
||||
{.id = "trailing", .title = "Last spill", .day = year{2026} / August / day{11},
|
||||
.time_of_day = std::nullopt, .all_day = true,
|
||||
.end_time_of_day = std::nullopt, .start_day = {}, .end_day = {},
|
||||
.location = {}, .description = {}, .detail_title = {},
|
||||
.detail_all_day = true, .detail_start_time_of_day = std::nullopt,
|
||||
.segment = nocal::tui::ItemSegment::single, .recurring = false,
|
||||
.recurrence_summary = {}, .source_time_zone = {}},
|
||||
};
|
||||
|
||||
const auto frame = nocal::tui::render_month(items, state);
|
||||
check(ordered(frame_line(frame, 1), {"We", "Th", "Fr", "Sa", "Su", "Mo", "Tu"}),
|
||||
"Wednesday start rotates the compact weekday header");
|
||||
check(frame_line(frame, 2).starts_with(" 1 "),
|
||||
"Wednesday start places July 1 in the first compact cell");
|
||||
check(frame_line(frame, 7).find("11•") != std::string_view::npos,
|
||||
"compact rendering marks an event in the final spill cell");
|
||||
check(frame.find("\x1b[") == std::string::npos,
|
||||
"Wednesday-start compact rendering supports ANSI-off output");
|
||||
check(std::count(frame.begin(), frame.end(), '\n') == 11,
|
||||
"Wednesday-start compact rendering preserves exact requested height");
|
||||
}
|
||||
|
||||
void test_monday_default_and_invalid_week_start_fallback()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
auto default_state = nocal::tui::ScreenState{};
|
||||
default_state.visible_month = year{2026} / July;
|
||||
default_state.selected_day = year{2026} / July / day{17};
|
||||
default_state.today = default_state.selected_day;
|
||||
default_state.width = 72;
|
||||
default_state.height = 20;
|
||||
default_state.ansi = false;
|
||||
|
||||
auto explicit_monday = default_state;
|
||||
explicit_monday.week_start = Monday;
|
||||
const auto default_frame = nocal::tui::render_month(
|
||||
std::span<const nocal::tui::CalendarItem>{}, default_state);
|
||||
const auto explicit_frame = nocal::tui::render_month(
|
||||
std::span<const nocal::tui::CalendarItem>{}, explicit_monday);
|
||||
check(default_frame == explicit_frame,
|
||||
"default week start remains byte-compatible with explicit Monday");
|
||||
check(ordered(frame_line(default_frame, 1), {"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"}),
|
||||
"default full header remains Monday-first");
|
||||
|
||||
auto invalid = default_state;
|
||||
invalid.week_start = weekday{8};
|
||||
const auto invalid_frame = nocal::tui::render_month(
|
||||
std::span<const nocal::tui::CalendarItem>{}, invalid);
|
||||
check(invalid_frame == default_frame,
|
||||
"invalid week-start state safely falls back to Monday");
|
||||
}
|
||||
|
||||
void test_calendar_picker_frame()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
@@ -293,6 +437,9 @@ int main()
|
||||
{
|
||||
test_full_month_render();
|
||||
test_compact_and_input();
|
||||
test_sunday_start_full_render_and_domain_window();
|
||||
test_wednesday_start_compact_render();
|
||||
test_monday_default_and_invalid_week_start_fallback();
|
||||
test_navigation();
|
||||
test_search_frames();
|
||||
test_calendar_picker_frame();
|
||||
|
||||
Reference in New Issue
Block a user