feat: add safe calendar import and export
Add explicit noninteractive CLI transfer modes with full event validation, idempotent duplicate handling, collision refusal, atomic import backups, and no-overwrite export creation. Cover pure merge semantics and end-to-end rollback paths for unsupported content, invalid identities, conflicts, option combinations, and existing destinations.
This commit is contained in:
27
README.md
27
README.md
@@ -6,8 +6,9 @@ inherits its colors from your terminal theme.
|
||||
|
||||
This repository currently contains the first local-calendar vertical slice:
|
||||
date/domain logic, guarded atomic iCalendar persistence, add/edit/delete forms,
|
||||
a responsive ANSI terminal UI with month and agenda views, tests, and Linux
|
||||
launch integration. See [the product specification](docs/PRODUCT.md),
|
||||
a responsive ANSI terminal UI with month and agenda views, explicit safe
|
||||
calendar import/export, tests, and Linux launch integration. See
|
||||
[the product specification](docs/PRODUCT.md),
|
||||
[architecture](docs/ARCHITECTURE.md), and [roadmap](docs/ROADMAP.md).
|
||||
|
||||
## Controls
|
||||
@@ -46,6 +47,28 @@ Run the tests with `meson test -C build --print-errorlogs`. Nocal reads
|
||||
positional argument. `--demo` adds a few unsaved sample appointments and
|
||||
`--print` emits a non-interactive frame.
|
||||
|
||||
Calendar interchange is explicit, non-interactive, and network-free:
|
||||
|
||||
```sh
|
||||
nocal --import SOURCE.ics --calendar TARGET.ics
|
||||
nocal --export DESTINATION.ics --calendar SOURCE.ics
|
||||
```
|
||||
|
||||
Import atomically merges source events into the target. An event whose UID and
|
||||
all fields exactly match an existing event is skipped. The complete operation
|
||||
is rejected before the target is written if either file is unsafe to
|
||||
round-trip or contains empty or duplicate UIDs or invalid intervals, or if an
|
||||
imported UID collides with a different target event. A successful merge that
|
||||
adds events retains the target's exact previous bytes as `TARGET.ics.bak`; an
|
||||
import containing only exact duplicates does not rewrite the target or its
|
||||
backup.
|
||||
|
||||
Export validates the source and atomically creates a canonical calendar at the
|
||||
destination. It refuses to overwrite any existing destination and rejects a
|
||||
source that fails event validation or that Nocal cannot safely round-trip.
|
||||
`--import` and `--export` are mutually exclusive and cannot be combined with
|
||||
`--demo`, `--print`, or `--restore-backup`.
|
||||
|
||||
Nocal expands and safely round-trips a focused iCalendar recurrence subset:
|
||||
daily, weekly, monthly, and yearly rules with interval, count or until, common
|
||||
weekly/monthly/yearly selectors, `EXDATE`, floating times, UTC, and system IANA
|
||||
|
||||
@@ -82,6 +82,25 @@ does not consume the backup. The TUI mutates a copyable in-memory model and
|
||||
rolls it back if persistence fails. Its bounded undo/redo history contains only
|
||||
mutations that crossed the persistence boundary successfully.
|
||||
|
||||
Explicit CLI import and export reuse the storage parser, safety classification,
|
||||
validation, and atomic persistence boundary; these workflows do not depend on
|
||||
the TUI and perform no network access. Import loads and validates both source
|
||||
and target before staging a target replacement. UIDs in both files must be
|
||||
non-empty and unique, every interval must be valid, and every component in both
|
||||
files must be safe to round-trip. Matching UID plus identical fields is an
|
||||
idempotent duplicate; matching UID with different fields is a collision that
|
||||
aborts the whole operation. A committed merge writes the exact old target to
|
||||
`.bak` before atomic replacement. If every source event is already present
|
||||
exactly, import performs no write and does not alter the backup.
|
||||
|
||||
Export accepts only a valid, safely round-trippable source, serializes a
|
||||
canonical calendar through private staging, and atomically creates a destination
|
||||
that must not already exist. Destination refusal is part of the
|
||||
commit boundary, so export never replaces an existing path. Import, export,
|
||||
demo, non-interactive frame printing, and backup restoration are distinct
|
||||
top-level application modes; import and export cannot be selected together or
|
||||
combined with any of the other modes.
|
||||
|
||||
The current writer deliberately refuses to mutate an existing file when the
|
||||
loader encounters information it cannot round-trip, including recurrence
|
||||
overrides, `RDATE`, custom `VTIMEZONE` definitions, alarms, attendees, unknown
|
||||
|
||||
@@ -81,7 +81,7 @@ The full terminal is treated as a responsive canvas:
|
||||
|
||||
The first usable foundation supports day and appointment navigation, a
|
||||
full-frame reader, a read-only 42-day agenda, validated add/edit/delete forms,
|
||||
and atomic `.ics` writes.
|
||||
atomic `.ics` writes, and explicit non-interactive calendar import/export.
|
||||
Dense days keep every appointment keyboard-reachable even when all lines do
|
||||
not fit in the cell. Unknown or unsupported iCalendar content makes a source
|
||||
read-only instead of being discarded. Saves reject external changes, retain a
|
||||
@@ -101,6 +101,27 @@ agenda. Agenda navigation never persists state, and add/edit/delete remain in
|
||||
the month workflow. The next local-data work adds advanced recurrence overrides
|
||||
and rule editing.
|
||||
|
||||
## Local file interchange
|
||||
|
||||
Import and export are explicit command-line workflows. They never contact the
|
||||
network and do not enter the interactive TUI.
|
||||
|
||||
- `nocal --import SOURCE --calendar TARGET` atomically merges events into the
|
||||
target. Exact same-UID, same-field events are duplicates and are skipped. A
|
||||
differing same-UID event, an empty or duplicate UID or invalid event interval
|
||||
in either file, or content in either file that cannot be safely round-tripped
|
||||
rejects the complete import before the target is written. A successful
|
||||
merge keeps the target's exact previous bytes as its adjacent `.bak`; a
|
||||
duplicate-only import leaves both target and backup untouched.
|
||||
- `nocal --export DESTINATION --calendar SOURCE` validates the source and
|
||||
atomically creates a canonical calendar. It never overwrites an existing
|
||||
destination and refuses any source whose events are invalid or that cannot
|
||||
be safely round-tripped.
|
||||
|
||||
The two operations are mutually exclusive and cannot be combined with demo,
|
||||
frame printing, or backup restoration. These restrictions keep destructive
|
||||
ambiguity out of automation and make every file-writing mode deliberate.
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Information is never encoded by color alone.
|
||||
|
||||
@@ -12,13 +12,14 @@
|
||||
- Occurrence-aware appointment search with exact result-to-grid navigation
|
||||
- Session calendar toggles shared by month rendering, focus, and search
|
||||
- Read-only 42-day agenda with bounded recurrence and exact return navigation
|
||||
- Explicit atomic import/export with validation, collision refusal, and backups
|
||||
- Meson build, Nix development shell, desktop entry, and Hyprland launcher
|
||||
- Seeded sample data when explicitly requested, never silent data mutation
|
||||
|
||||
## 0.1 — complete local calendar
|
||||
|
||||
- Advanced recurrence editing, RDATE/overrides, and embedded VTIMEZONE support
|
||||
- Import/export and configurable week start
|
||||
- Configurable week start
|
||||
- Screen-reader-friendly linear view and full Unicode display-width handling
|
||||
|
||||
## 0.2 — durable cache and provider boundary
|
||||
|
||||
28
include/nocal/domain/calendar_transfer.hpp
Normal file
28
include/nocal/domain/calendar_transfer.hpp
Normal file
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include "nocal/domain/event.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
namespace nocal {
|
||||
|
||||
struct ImportMergeResult {
|
||||
std::vector<Event> events;
|
||||
std::size_t imported{};
|
||||
std::size_t duplicates_skipped{};
|
||||
};
|
||||
|
||||
// Transfer inputs must contain valid intervals and one non-empty, unique UID
|
||||
// per event. Invalid input throws std::invalid_argument before any persistence
|
||||
// boundary is crossed.
|
||||
void validate_events_for_export(std::span<const Event> events);
|
||||
|
||||
// Preserves target order and appends new source events in source order. An
|
||||
// exactly equivalent source/target UID is skipped; a differing UID collision
|
||||
// throws std::invalid_argument and returns no partial result.
|
||||
[[nodiscard]] ImportMergeResult merge_events_for_import(
|
||||
std::span<const Event> target, std::span<const Event> source);
|
||||
|
||||
} // namespace nocal
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include "nocal/domain/calendar_transfer.hpp"
|
||||
#include "nocal/domain/date.hpp"
|
||||
#include "nocal/domain/event.hpp"
|
||||
#include "nocal/domain/event_query.hpp"
|
||||
|
||||
@@ -13,6 +13,7 @@ project(
|
||||
|
||||
inc = include_directories('include')
|
||||
nocal_sources = files(
|
||||
'src/domain/calendar_transfer.cpp',
|
||||
'src/domain/date.cpp',
|
||||
'src/domain/event_query.cpp',
|
||||
'src/domain/month_layout.cpp',
|
||||
@@ -30,6 +31,10 @@ nocal_executable = executable('nocal', 'src/main.cpp', include_directories: inc,
|
||||
domain_tests = executable('domain-tests', 'tests/domain_tests.cpp',
|
||||
include_directories: inc, link_with: nocal_lib)
|
||||
test('domain', domain_tests)
|
||||
calendar_transfer_tests = executable('calendar-transfer-tests',
|
||||
'tests/calendar_transfer_tests.cpp', include_directories: inc,
|
||||
link_with: nocal_lib)
|
||||
test('calendar-transfer', calendar_transfer_tests)
|
||||
ics_tests = executable('ics-tests', 'tests/ics_tests.cpp',
|
||||
include_directories: inc, link_with: nocal_lib)
|
||||
test('ics-storage', ics_tests)
|
||||
@@ -46,6 +51,8 @@ test('editor', editor_tests)
|
||||
shell = find_program('sh')
|
||||
test('cli-recovery', shell,
|
||||
args: [files('tests/cli_recovery_test.sh'), nocal_executable])
|
||||
test('cli-transfer', shell,
|
||||
args: [files('tests/cli_transfer_test.sh'), nocal_executable])
|
||||
|
||||
install_data('scripts/nocal-hyprland', install_dir: get_option('bindir'),
|
||||
install_mode: 'rwxr-xr-x')
|
||||
|
||||
93
src/domain/calendar_transfer.cpp
Normal file
93
src/domain/calendar_transfer.cpp
Normal file
@@ -0,0 +1,93 @@
|
||||
#include "nocal/domain/calendar_transfer.hpp"
|
||||
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace nocal {
|
||||
namespace {
|
||||
|
||||
[[nodiscard]] bool events_are_semantically_equal(const Event& left,
|
||||
const Event& right)
|
||||
{
|
||||
return left.uid == right.uid && left.title == right.title &&
|
||||
left.start == right.start && left.end == right.end &&
|
||||
left.all_day == right.all_day && left.location == right.location &&
|
||||
left.description == right.description &&
|
||||
left.calendar_id == right.calendar_id && left.color == right.color &&
|
||||
left.time_basis == right.time_basis &&
|
||||
left.time_zone == right.time_zone &&
|
||||
left.recurrence == right.recurrence &&
|
||||
left.recurrence_exceptions == right.recurrence_exceptions;
|
||||
}
|
||||
|
||||
void validate_events(const std::span<const Event> events,
|
||||
const std::string_view collection_name)
|
||||
{
|
||||
std::unordered_set<std::string_view> uids;
|
||||
uids.reserve(events.size());
|
||||
|
||||
for (const auto& event : events) {
|
||||
if (event.uid.empty()) {
|
||||
throw std::invalid_argument{std::string{collection_name} +
|
||||
" event has an empty UID"};
|
||||
}
|
||||
if (!has_valid_interval(event)) {
|
||||
throw std::invalid_argument{std::string{collection_name} +
|
||||
" event '" + event.uid +
|
||||
"' ends before it starts"};
|
||||
}
|
||||
if (!uids.emplace(event.uid).second) {
|
||||
throw std::invalid_argument{std::string{collection_name} +
|
||||
" contains duplicate UID '" + event.uid +
|
||||
"'"};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void validate_events_for_export(const std::span<const Event> events)
|
||||
{
|
||||
validate_events(events, "export");
|
||||
}
|
||||
|
||||
ImportMergeResult merge_events_for_import(const std::span<const Event> target,
|
||||
const std::span<const Event> source)
|
||||
{
|
||||
validate_events(target, "target");
|
||||
validate_events(source, "source");
|
||||
|
||||
std::unordered_map<std::string_view, const Event*> target_by_uid;
|
||||
target_by_uid.reserve(target.size());
|
||||
for (const auto& event : target) target_by_uid.emplace(event.uid, &event);
|
||||
|
||||
std::size_t imported = 0;
|
||||
std::size_t duplicates_skipped = 0;
|
||||
for (const auto& event : source) {
|
||||
const auto match = target_by_uid.find(event.uid);
|
||||
if (match == target_by_uid.end()) {
|
||||
++imported;
|
||||
continue;
|
||||
}
|
||||
if (!events_are_semantically_equal(*match->second, event)) {
|
||||
throw std::invalid_argument{"import UID collision for '" + event.uid +
|
||||
"' differs from the target event"};
|
||||
}
|
||||
++duplicates_skipped;
|
||||
}
|
||||
|
||||
ImportMergeResult result;
|
||||
result.events.reserve(target.size() + imported);
|
||||
result.events.insert(result.events.end(), target.begin(), target.end());
|
||||
for (const auto& event : source) {
|
||||
if (!target_by_uid.contains(event.uid)) result.events.push_back(event);
|
||||
}
|
||||
result.imported = imported;
|
||||
result.duplicates_skipped = duplicates_skipped;
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace nocal
|
||||
115
src/main.cpp
115
src/main.cpp
@@ -1,3 +1,4 @@
|
||||
#include "nocal/domain/calendar_transfer.hpp"
|
||||
#include "nocal/domain/date.hpp"
|
||||
#include "nocal/storage/ics_store.hpp"
|
||||
#include "nocal/tui/Screen.hpp"
|
||||
@@ -23,6 +24,8 @@ constexpr std::string_view version = "0.1.0";
|
||||
|
||||
struct Options {
|
||||
std::filesystem::path calendar_path;
|
||||
std::optional<std::filesystem::path> import_path;
|
||||
std::optional<std::filesystem::path> export_path;
|
||||
bool demo{false};
|
||||
bool print{false};
|
||||
bool restore_backup{false};
|
||||
@@ -50,6 +53,8 @@ void print_help(std::ostream& output)
|
||||
" --demo add sample appointments without saving them\n"
|
||||
" --print print one plain-text frame and exit\n"
|
||||
" --restore-backup restore PATH.bak over PATH and exit\n"
|
||||
" --import PATH atomically merge PATH into the calendar and exit\n"
|
||||
" --export PATH atomically export to a new PATH and exit\n"
|
||||
" --no-color disable ANSI styling\n"
|
||||
" -h, --help show this help\n"
|
||||
" -V, --version show the version\n\n"
|
||||
@@ -65,7 +70,15 @@ void print_help(std::ostream& output)
|
||||
|
||||
Options parse_options(int argc, char** argv)
|
||||
{
|
||||
Options options{.calendar_path = default_calendar_path()};
|
||||
Options options{
|
||||
.calendar_path = default_calendar_path(),
|
||||
.import_path = std::nullopt,
|
||||
.export_path = std::nullopt,
|
||||
.demo = false,
|
||||
.print = false,
|
||||
.restore_backup = false,
|
||||
.no_color = false,
|
||||
};
|
||||
bool positional_seen = false;
|
||||
for (int index = 1; index < argc; ++index) {
|
||||
const std::string_view argument{argv[index]};
|
||||
@@ -85,6 +98,17 @@ Options parse_options(int argc, char** argv)
|
||||
options.restore_backup = true;
|
||||
} else if (argument == "--no-color") {
|
||||
options.no_color = true;
|
||||
} else if (argument == "--import" || argument == "--export") {
|
||||
if (++index >= argc) {
|
||||
throw std::invalid_argument(std::string{argument} + " requires a path");
|
||||
}
|
||||
auto& destination = argument == "--import"
|
||||
? options.import_path : options.export_path;
|
||||
if (destination) {
|
||||
throw std::invalid_argument(std::string{argument} +
|
||||
" may only be supplied once");
|
||||
}
|
||||
destination = std::filesystem::path{argv[index]};
|
||||
} else if (argument == "-c" || argument == "--calendar") {
|
||||
if (++index >= argc) {
|
||||
throw std::invalid_argument(std::string{argument} + " requires a path");
|
||||
@@ -106,6 +130,20 @@ Options parse_options(int argc, char** argv)
|
||||
if (options.restore_backup && options.print) {
|
||||
throw std::invalid_argument("--restore-backup cannot be combined with --print");
|
||||
}
|
||||
if (options.import_path && options.export_path) {
|
||||
throw std::invalid_argument("--import cannot be combined with --export");
|
||||
}
|
||||
const bool transfer = options.import_path || options.export_path;
|
||||
if (transfer && options.demo) {
|
||||
throw std::invalid_argument("--import/--export cannot be combined with --demo");
|
||||
}
|
||||
if (transfer && options.print) {
|
||||
throw std::invalid_argument("--import/--export cannot be combined with --print");
|
||||
}
|
||||
if (transfer && options.restore_backup) {
|
||||
throw std::invalid_argument(
|
||||
"--import/--export cannot be combined with --restore-backup");
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
@@ -172,6 +210,70 @@ int print_frame(std::span<const nocal::Event> events, bool no_color)
|
||||
return std::cout ? EXIT_SUCCESS : EXIT_FAILURE;
|
||||
}
|
||||
|
||||
void print_warnings(const nocal::storage::LoadResult& loaded,
|
||||
const std::filesystem::path& path)
|
||||
{
|
||||
for (const auto& warning : loaded.warnings) {
|
||||
std::cerr << "nocal: " << path.string() << ": " << warning << '\n';
|
||||
}
|
||||
}
|
||||
|
||||
void require_transfer_safe(const nocal::storage::LoadResult& loaded,
|
||||
const std::string_view role)
|
||||
{
|
||||
if (!loaded.safe_to_rewrite) {
|
||||
throw std::runtime_error(std::string{role} +
|
||||
" contains unsupported iCalendar data");
|
||||
}
|
||||
}
|
||||
|
||||
int import_calendar(const std::filesystem::path& source_path,
|
||||
const std::filesystem::path& target_path,
|
||||
nocal::storage::LoadResult target)
|
||||
{
|
||||
const auto source = nocal::storage::IcsStore::load(source_path);
|
||||
if (!source.revision.existed()) {
|
||||
throw std::runtime_error("import source does not exist: " + source_path.string());
|
||||
}
|
||||
print_warnings(source, source_path);
|
||||
require_transfer_safe(target, "target calendar");
|
||||
require_transfer_safe(source, "import source");
|
||||
auto merged = nocal::merge_events_for_import(target.events, source.events);
|
||||
if (merged.imported > 0) {
|
||||
(void)nocal::storage::IcsStore::save(target_path, merged.events, target.revision);
|
||||
}
|
||||
const auto appointment_word = merged.imported == 1 ? " appointment" : " appointments";
|
||||
const auto duplicate_suffix = merged.duplicates_skipped == 1 ? "" : "s";
|
||||
std::cout << "Imported " << merged.imported << appointment_word << " from "
|
||||
<< source_path.string() << " into " << target_path.string() << "; "
|
||||
<< merged.duplicates_skipped << " duplicate" << duplicate_suffix
|
||||
<< " skipped.\n";
|
||||
return std::cout ? EXIT_SUCCESS : EXIT_FAILURE;
|
||||
}
|
||||
|
||||
int export_calendar(const std::filesystem::path& source_path,
|
||||
const std::filesystem::path& destination_path,
|
||||
const nocal::storage::LoadResult& source)
|
||||
{
|
||||
if (!source.revision.existed()) {
|
||||
throw std::runtime_error("export source does not exist: " + source_path.string());
|
||||
}
|
||||
require_transfer_safe(source, "export source");
|
||||
nocal::validate_events_for_export(source.events);
|
||||
const auto destination = nocal::storage::IcsStore::load(destination_path);
|
||||
if (destination.revision.existed()) {
|
||||
throw std::runtime_error("export destination already exists: " +
|
||||
destination_path.string());
|
||||
}
|
||||
(void)nocal::storage::IcsStore::save(
|
||||
destination_path, source.events, destination.revision);
|
||||
const auto appointment_word = source.events.size() == 1
|
||||
? " appointment" : " appointments";
|
||||
std::cout << "Exported " << source.events.size() << appointment_word << " from "
|
||||
<< source_path.string() << " to " << destination_path.string() << ".\n";
|
||||
return std::cout ? EXIT_SUCCESS : EXIT_FAILURE;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv)
|
||||
@@ -179,9 +281,7 @@ int main(int argc, char** argv)
|
||||
try {
|
||||
const auto options = parse_options(argc, argv);
|
||||
auto loaded = nocal::storage::IcsStore::load(options.calendar_path);
|
||||
for (const auto& warning : loaded.warnings) {
|
||||
std::cerr << "nocal: " << warning << '\n';
|
||||
}
|
||||
print_warnings(loaded, options.calendar_path);
|
||||
if (options.restore_backup) {
|
||||
const auto backup = nocal::storage::IcsStore::backup_path(options.calendar_path);
|
||||
nocal::storage::IcsStore::restore_backup(
|
||||
@@ -190,6 +290,13 @@ int main(int argc, char** argv)
|
||||
<< options.calendar_path.string() << '\n';
|
||||
return std::cout ? EXIT_SUCCESS : EXIT_FAILURE;
|
||||
}
|
||||
if (options.import_path) {
|
||||
return import_calendar(*options.import_path, options.calendar_path,
|
||||
std::move(loaded));
|
||||
}
|
||||
if (options.export_path) {
|
||||
return export_calendar(options.calendar_path, *options.export_path, loaded);
|
||||
}
|
||||
if (options.demo) {
|
||||
auto samples = demo_events();
|
||||
loaded.events.insert(loaded.events.end(), samples.begin(), samples.end());
|
||||
|
||||
279
tests/calendar_transfer_tests.cpp
Normal file
279
tests/calendar_transfer_tests.cpp
Normal file
@@ -0,0 +1,279 @@
|
||||
#include "nocal/domain/calendar_transfer.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <functional>
|
||||
#include <iostream>
|
||||
#include <optional>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
int failures = 0;
|
||||
|
||||
void check(const bool condition, const std::string_view message)
|
||||
{
|
||||
if (!condition) {
|
||||
++failures;
|
||||
std::cerr << "FAIL: " << message << '\n';
|
||||
}
|
||||
}
|
||||
|
||||
template <class Function>
|
||||
void check_invalid_argument(Function&& function, const std::string_view message)
|
||||
{
|
||||
try {
|
||||
std::forward<Function>(function)();
|
||||
check(false, message);
|
||||
} catch (const std::invalid_argument&) {
|
||||
} catch (...) {
|
||||
check(false, message);
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] nocal::TimePoint at(const int hour, const int minute = 0)
|
||||
{
|
||||
using namespace std::chrono;
|
||||
return nocal::TimePoint{hours{24 * 20'000 + hour} + minutes{minute}};
|
||||
}
|
||||
|
||||
[[nodiscard]] nocal::Event complete_event(std::string uid)
|
||||
{
|
||||
using namespace std::chrono;
|
||||
nocal::Event event;
|
||||
event.uid = std::move(uid);
|
||||
event.title = "Planning";
|
||||
event.start = at(9);
|
||||
event.end = at(10, 30);
|
||||
event.all_day = false;
|
||||
event.location = "Room 3";
|
||||
event.description = "Discuss launch";
|
||||
event.calendar_id = "work";
|
||||
event.color = "blue";
|
||||
event.time_basis = nocal::TimeBasis::zoned;
|
||||
event.time_zone = "Europe/London";
|
||||
nocal::RecurrenceRule recurrence;
|
||||
recurrence.frequency = nocal::RecurrenceFrequency::weekly;
|
||||
recurrence.interval = 2;
|
||||
recurrence.count = 6;
|
||||
recurrence.until = at(24);
|
||||
recurrence.by_weekday = {Monday, Wednesday};
|
||||
recurrence.by_month_day = {1, -1};
|
||||
recurrence.by_month = {1, 7};
|
||||
event.recurrence = std::move(recurrence);
|
||||
event.recurrence_exceptions = {at(16), at(18)};
|
||||
return event;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool equal(const nocal::Event& left, const nocal::Event& right)
|
||||
{
|
||||
return left.uid == right.uid && left.title == right.title &&
|
||||
left.start == right.start && left.end == right.end &&
|
||||
left.all_day == right.all_day && left.location == right.location &&
|
||||
left.description == right.description &&
|
||||
left.calendar_id == right.calendar_id && left.color == right.color &&
|
||||
left.time_basis == right.time_basis &&
|
||||
left.time_zone == right.time_zone &&
|
||||
left.recurrence == right.recurrence &&
|
||||
left.recurrence_exceptions == right.recurrence_exceptions;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool equal(const std::vector<nocal::Event>& left,
|
||||
const std::vector<nocal::Event>& right)
|
||||
{
|
||||
if (left.size() != right.size()) return false;
|
||||
for (std::size_t index = 0; index < left.size(); ++index) {
|
||||
if (!equal(left[index], right[index])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void test_export_validation()
|
||||
{
|
||||
nocal::validate_events_for_export({});
|
||||
const std::vector valid{complete_event("one"), complete_event("two")};
|
||||
nocal::validate_events_for_export(valid);
|
||||
|
||||
auto missing_uid = valid;
|
||||
missing_uid[0].uid.clear();
|
||||
check_invalid_argument(
|
||||
[&] { nocal::validate_events_for_export(missing_uid); },
|
||||
"export rejects an empty UID");
|
||||
|
||||
auto duplicate_uid = valid;
|
||||
duplicate_uid[1].uid = duplicate_uid[0].uid;
|
||||
check_invalid_argument(
|
||||
[&] { nocal::validate_events_for_export(duplicate_uid); },
|
||||
"export rejects duplicate UIDs even when event fields differ");
|
||||
|
||||
auto backwards = valid;
|
||||
backwards[0].end = backwards[0].start - std::chrono::minutes{1};
|
||||
check_invalid_argument([&] { nocal::validate_events_for_export(backwards); },
|
||||
"export rejects a backwards interval");
|
||||
|
||||
auto zero_duration = complete_event("instant");
|
||||
zero_duration.end = zero_duration.start;
|
||||
nocal::validate_events_for_export(std::vector{zero_duration});
|
||||
}
|
||||
|
||||
void test_merge_success_order_and_counts()
|
||||
{
|
||||
auto first = complete_event("target-one");
|
||||
auto second = complete_event("target-two");
|
||||
second.title = "Second target";
|
||||
auto duplicate = second;
|
||||
auto third = complete_event("source-one");
|
||||
third.title = "First import";
|
||||
auto fourth = complete_event("source-two");
|
||||
fourth.title = "Second import";
|
||||
|
||||
const std::vector target{first, second};
|
||||
const std::vector source{third, duplicate, fourth};
|
||||
const auto result = nocal::merge_events_for_import(target, source);
|
||||
|
||||
check(result.events.size() == 4, "merge returns target and new source events");
|
||||
check(result.imported == 2, "merge reports the number imported");
|
||||
check(result.duplicates_skipped == 1,
|
||||
"merge reports exact duplicates skipped");
|
||||
check(result.events.size() == 4 && result.events[0].uid == "target-one" &&
|
||||
result.events[1].uid == "target-two" &&
|
||||
result.events[2].uid == "source-one" &&
|
||||
result.events[3].uid == "source-two",
|
||||
"merge preserves target order and appends source order");
|
||||
check(equal(result.events[1], duplicate),
|
||||
"an all-field exact duplicate is skipped without replacing target");
|
||||
}
|
||||
|
||||
void test_every_event_field_participates_in_collision_equality()
|
||||
{
|
||||
using Mutation = std::pair<std::string_view, std::function<void(nocal::Event&)>>;
|
||||
const std::vector<Mutation> mutations{
|
||||
{"title", [](auto& event) { event.title += " changed"; }},
|
||||
{"start", [](auto& event) { event.start += std::chrono::minutes{1}; }},
|
||||
{"end", [](auto& event) { event.end += std::chrono::minutes{1}; }},
|
||||
{"all-day", [](auto& event) { event.all_day = !event.all_day; }},
|
||||
{"location", [](auto& event) { event.location += " changed"; }},
|
||||
{"description", [](auto& event) { event.description += " changed"; }},
|
||||
{"calendar ID", [](auto& event) { event.calendar_id += "-other"; }},
|
||||
{"color", [](auto& event) { event.color += "-other"; }},
|
||||
{"time basis", [](auto& event) { event.time_basis = nocal::TimeBasis::utc; }},
|
||||
{"time zone", [](auto& event) { event.time_zone = "America/New_York"; }},
|
||||
{"recurrence", [](auto& event) { event.recurrence->interval += 1; }},
|
||||
{"recurrence exclusions",
|
||||
[](auto& event) { event.recurrence_exceptions.push_back(at(19)); }},
|
||||
};
|
||||
|
||||
const std::vector target{complete_event("same-uid")};
|
||||
for (const auto& [field, mutate] : mutations) {
|
||||
auto collision = target[0];
|
||||
mutate(collision);
|
||||
check_invalid_argument(
|
||||
[&] {
|
||||
(void)nocal::merge_events_for_import(
|
||||
target, std::vector<nocal::Event>{collision});
|
||||
},
|
||||
std::string{"UID collision rejects a differing "} +
|
||||
std::string{field});
|
||||
}
|
||||
|
||||
auto removed_recurrence = target[0];
|
||||
removed_recurrence.recurrence.reset();
|
||||
check_invalid_argument(
|
||||
[&] {
|
||||
(void)nocal::merge_events_for_import(
|
||||
target, std::vector<nocal::Event>{removed_recurrence});
|
||||
},
|
||||
"UID collision rejects recurrence presence mismatch");
|
||||
}
|
||||
|
||||
void test_merge_validates_both_collections_before_result()
|
||||
{
|
||||
const auto target_event = complete_event("target");
|
||||
const auto source_event = complete_event("source");
|
||||
|
||||
auto empty_target_uid = std::vector{target_event};
|
||||
empty_target_uid[0].uid.clear();
|
||||
check_invalid_argument(
|
||||
[&] { (void)nocal::merge_events_for_import(empty_target_uid, {}); },
|
||||
"merge rejects an empty target UID");
|
||||
|
||||
const std::vector duplicate_target{target_event, target_event};
|
||||
check_invalid_argument(
|
||||
[&] { (void)nocal::merge_events_for_import(duplicate_target, {}); },
|
||||
"merge rejects duplicate target UIDs");
|
||||
|
||||
auto empty_source_uid = std::vector{source_event};
|
||||
empty_source_uid[0].uid.clear();
|
||||
check_invalid_argument(
|
||||
[&] { (void)nocal::merge_events_for_import({}, empty_source_uid); },
|
||||
"merge rejects an empty source UID");
|
||||
|
||||
const std::vector duplicate_source{source_event, source_event};
|
||||
check_invalid_argument(
|
||||
[&] { (void)nocal::merge_events_for_import({}, duplicate_source); },
|
||||
"merge rejects duplicate source UIDs");
|
||||
|
||||
auto invalid_target = std::vector{target_event};
|
||||
invalid_target[0].end = invalid_target[0].start - std::chrono::seconds{1};
|
||||
check_invalid_argument(
|
||||
[&] { (void)nocal::merge_events_for_import(invalid_target, {}); },
|
||||
"merge rejects a backwards target interval");
|
||||
|
||||
auto invalid_source = std::vector{source_event};
|
||||
invalid_source[0].end = invalid_source[0].start - std::chrono::seconds{1};
|
||||
check_invalid_argument(
|
||||
[&] { (void)nocal::merge_events_for_import({}, invalid_source); },
|
||||
"merge rejects a backwards source interval");
|
||||
}
|
||||
|
||||
void test_inputs_are_not_mutated_on_failure()
|
||||
{
|
||||
auto target_event = complete_event("collision");
|
||||
auto source_event = target_event;
|
||||
source_event.description = "Different data";
|
||||
source_event.recurrence_exceptions.push_back(at(21));
|
||||
|
||||
std::vector target{target_event};
|
||||
std::vector source{source_event};
|
||||
const auto target_before = target;
|
||||
const auto source_before = source;
|
||||
|
||||
check_invalid_argument(
|
||||
[&] { (void)nocal::merge_events_for_import(target, source); },
|
||||
"a differing collision fails the whole merge");
|
||||
check(equal(target, target_before), "failed merge does not mutate target input");
|
||||
check(equal(source, source_before), "failed merge does not mutate source input");
|
||||
|
||||
source.push_back(complete_event("new-before-invalid"));
|
||||
auto invalid = complete_event("invalid-after-new");
|
||||
invalid.end = invalid.start - std::chrono::hours{1};
|
||||
source.push_back(invalid);
|
||||
const auto source_with_invalid_before = source;
|
||||
check_invalid_argument(
|
||||
[&] { (void)nocal::merge_events_for_import(target, source); },
|
||||
"source validation fails before exposing an earlier import");
|
||||
check(equal(source, source_with_invalid_before),
|
||||
"validation failure after a valid source item leaves source unchanged");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main()
|
||||
{
|
||||
test_export_validation();
|
||||
test_merge_success_order_and_counts();
|
||||
test_every_event_field_participates_in_collision_equality();
|
||||
test_merge_validates_both_collections_before_result();
|
||||
test_inputs_are_not_mutated_on_failure();
|
||||
|
||||
if (failures != 0) {
|
||||
std::cerr << failures << " calendar transfer test(s) failed\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
std::cout << "calendar transfer tests passed\n";
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
280
tests/cli_transfer_test.sh
Normal file
280
tests/cli_transfer_test.sh
Normal file
@@ -0,0 +1,280 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "$#" -ne 1 ]; then
|
||||
echo "usage: cli_transfer_test.sh /path/to/nocal" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
nocal=$1
|
||||
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/nocal-cli-transfer.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
|
||||
}
|
||||
|
||||
write_target()
|
||||
{
|
||||
path=$1
|
||||
cat >"$path" <<'EOF'
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Nocal transfer CLI test//EN
|
||||
BEGIN:VEVENT
|
||||
UID:existing@nocal.test
|
||||
DTSTART:20260720T090000
|
||||
DTEND:20260720T100000
|
||||
SUMMARY:Existing appointment
|
||||
LOCATION:Desk
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
EOF
|
||||
}
|
||||
|
||||
source_calendar=$tmpdir/source.ics
|
||||
cat >"$source_calendar" <<'EOF'
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Nocal transfer CLI test//EN
|
||||
BEGIN:VEVENT
|
||||
UID:existing@nocal.test
|
||||
DTSTART:20260720T090000
|
||||
DTEND:20260720T100000
|
||||
SUMMARY:Existing appointment
|
||||
LOCATION:Desk
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:imported@nocal.test
|
||||
DTSTART:20260721T140000Z
|
||||
DTEND:20260721T150000Z
|
||||
SUMMARY:Imported appointment
|
||||
DESCRIPTION:Transferred deterministically
|
||||
X-NOCAL-CALENDAR-ID:work
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
EOF
|
||||
|
||||
# Import merges new appointments, skips exact duplicates, and preserves the
|
||||
# exact pre-import bytes as the adjacent backup.
|
||||
target=$tmpdir/target.ics
|
||||
write_target "$target"
|
||||
cp "$target" "$tmpdir/target-before.ics"
|
||||
import_summary=$(
|
||||
"$nocal" --import "$source_calendar" --calendar "$target"
|
||||
)
|
||||
printf '%s\n' "$import_summary" | grep -E 'Imported[[:space:]]+1([^0-9]|$)' >/dev/null ||
|
||||
fail "import summary does not report one imported appointment"
|
||||
printf '%s\n' "$import_summary" | grep -E '1[[:space:]]+duplicate' >/dev/null ||
|
||||
fail "import summary does not report one skipped duplicate"
|
||||
cmp "$target.bak" "$tmpdir/target-before.ics" >/dev/null ||
|
||||
fail "successful import backup does not contain the exact prior target bytes"
|
||||
grep -F 'UID:existing@nocal.test' "$target" >/dev/null
|
||||
grep -F 'UID:imported@nocal.test' "$target" >/dev/null
|
||||
|
||||
# A source containing only an exact duplicate must not rewrite the target or
|
||||
# create a backup.
|
||||
duplicate_source=$tmpdir/duplicate-source.ics
|
||||
write_target "$duplicate_source"
|
||||
duplicate_target=$tmpdir/duplicate-target.ics
|
||||
write_target "$duplicate_target"
|
||||
cp "$duplicate_target" "$tmpdir/duplicate-target-before.ics"
|
||||
duplicate_summary=$(
|
||||
"$nocal" --import "$duplicate_source" --calendar "$duplicate_target"
|
||||
)
|
||||
printf '%s\n' "$duplicate_summary" | grep -E 'Imported[[:space:]]+0([^0-9]|$)' >/dev/null ||
|
||||
fail "no-op import summary does not report zero imported appointments"
|
||||
printf '%s\n' "$duplicate_summary" | grep -E '1[[:space:]]+duplicate' >/dev/null ||
|
||||
fail "no-op import summary does not report one skipped duplicate"
|
||||
cmp "$duplicate_target" "$tmpdir/duplicate-target-before.ics" >/dev/null ||
|
||||
fail "exact-duplicate import rewrote the target"
|
||||
[ ! -e "$duplicate_target.bak" ] ||
|
||||
fail "exact-duplicate import created a backup despite performing no write"
|
||||
|
||||
# A differing appointment with the same UID is a conflict. No target or
|
||||
# backup may be produced by the failed mutation.
|
||||
conflict_source=$tmpdir/conflict-source.ics
|
||||
cat >"$conflict_source" <<'EOF'
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Nocal transfer CLI test//EN
|
||||
BEGIN:VEVENT
|
||||
UID:existing@nocal.test
|
||||
DTSTART:20260720T090000
|
||||
DTEND:20260720T100000
|
||||
SUMMARY:Conflicting appointment
|
||||
LOCATION:Desk
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
EOF
|
||||
conflict_target=$tmpdir/conflict-target.ics
|
||||
write_target "$conflict_target"
|
||||
cp "$conflict_target" "$tmpdir/conflict-target-before.ics"
|
||||
expect_failure "conflicting-UID import" \
|
||||
"$nocal" --import "$conflict_source" --calendar "$conflict_target"
|
||||
cmp "$conflict_target" "$tmpdir/conflict-target-before.ics" >/dev/null ||
|
||||
fail "conflicting-UID import changed the target"
|
||||
[ ! -e "$conflict_target.bak" ] ||
|
||||
fail "conflicting-UID import created a backup"
|
||||
|
||||
missing_uid=$tmpdir/missing-uid.ics
|
||||
cat >"$missing_uid" <<'EOF'
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Nocal transfer CLI test//EN
|
||||
BEGIN:VEVENT
|
||||
DTSTART:20260722T110000
|
||||
DTEND:20260722T120000
|
||||
SUMMARY:Missing identity
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
EOF
|
||||
|
||||
duplicate_uid=$tmpdir/duplicate-uid.ics
|
||||
cat >"$duplicate_uid" <<'EOF'
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Nocal transfer CLI test//EN
|
||||
BEGIN:VEVENT
|
||||
UID:duplicate@nocal.test
|
||||
DTSTART:20260722T110000
|
||||
DTEND:20260722T120000
|
||||
SUMMARY:First duplicate identity
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:duplicate@nocal.test
|
||||
DTSTART:20260723T110000
|
||||
DTEND:20260723T120000
|
||||
SUMMARY:Second duplicate identity
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
EOF
|
||||
|
||||
unsupported=$tmpdir/unsupported.ics
|
||||
cat >"$unsupported" <<'EOF'
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Nocal transfer CLI test//EN
|
||||
BEGIN:VEVENT
|
||||
UID:unsupported@nocal.test
|
||||
DTSTART:20260722T110000
|
||||
DTEND:20260722T120000
|
||||
SUMMARY:Unsupported appointment
|
||||
ATTENDEE:mailto:person@example.test
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
EOF
|
||||
|
||||
# Unsafe import sources are rejected before the target persistence boundary.
|
||||
for unsafe_source in "$missing_uid" "$duplicate_uid" "$unsupported"; do
|
||||
unsafe_name=$(basename "$unsafe_source" .ics)
|
||||
unsafe_target=$tmpdir/import-$unsafe_name-target.ics
|
||||
write_target "$unsafe_target"
|
||||
cp "$unsafe_target" "$tmpdir/import-$unsafe_name-before.ics"
|
||||
expect_failure "$unsafe_name import" \
|
||||
"$nocal" --import "$unsafe_source" --calendar "$unsafe_target"
|
||||
cmp "$unsafe_target" "$tmpdir/import-$unsafe_name-before.ics" >/dev/null ||
|
||||
fail "$unsafe_name import changed the target"
|
||||
[ ! -e "$unsafe_target.bak" ] ||
|
||||
fail "$unsafe_name import created a backup"
|
||||
done
|
||||
|
||||
# Identity validation applies equally to the target and leaves its exact bytes
|
||||
# untouched when it fails.
|
||||
duplicate_uid_target=$tmpdir/duplicate-uid-target.ics
|
||||
cp "$duplicate_uid" "$duplicate_uid_target"
|
||||
cp "$duplicate_uid_target" "$tmpdir/duplicate-uid-target-before.ics"
|
||||
expect_failure "duplicate-UID target import" \
|
||||
"$nocal" --import "$source_calendar" --calendar "$duplicate_uid_target"
|
||||
cmp "$duplicate_uid_target" "$tmpdir/duplicate-uid-target-before.ics" >/dev/null ||
|
||||
fail "import changed a duplicate-UID target"
|
||||
[ ! -e "$duplicate_uid_target.bak" ] ||
|
||||
fail "import into a duplicate-UID target created a backup"
|
||||
|
||||
# An unsafe target is read-only even when the source is valid.
|
||||
read_only_target=$tmpdir/read-only-target.ics
|
||||
cp "$unsupported" "$read_only_target"
|
||||
cp "$read_only_target" "$tmpdir/read-only-target-before.ics"
|
||||
expect_failure "read-only target import" \
|
||||
"$nocal" --import "$source_calendar" --calendar "$read_only_target"
|
||||
cmp "$read_only_target" "$tmpdir/read-only-target-before.ics" >/dev/null ||
|
||||
fail "import changed a read-only target"
|
||||
[ ! -e "$read_only_target.bak" ] ||
|
||||
fail "import into a read-only target created a backup"
|
||||
|
||||
# Export creates a new parseable calendar. Re-exporting that calendar yields
|
||||
# identical canonical bytes, demonstrating equivalent parsed appointments.
|
||||
exported=$tmpdir/exported.ics
|
||||
export_summary=$(
|
||||
"$nocal" --export "$exported" --calendar "$source_calendar"
|
||||
)
|
||||
printf '%s\n' "$export_summary" | grep -E 'Exported[[:space:]]+2([^0-9]|$)' >/dev/null ||
|
||||
fail "export summary does not report two exported appointments"
|
||||
[ -f "$exported" ] || fail "successful export did not create its destination"
|
||||
[ ! -e "$exported.bak" ] || fail "successful new export unexpectedly created a backup"
|
||||
canonical_copy=$tmpdir/exported-again.ics
|
||||
"$nocal" --export "$canonical_copy" --calendar "$exported" >"$tmpdir/out"
|
||||
cmp "$exported" "$canonical_copy" >/dev/null ||
|
||||
fail "exported calendar did not parse and round-trip equivalently"
|
||||
|
||||
# Existing export destinations are never overwritten or backed up.
|
||||
existing_destination=$tmpdir/existing-destination.ics
|
||||
printf '%s\n' 'sentinel destination bytes' >"$existing_destination"
|
||||
cp "$existing_destination" "$tmpdir/existing-destination-before"
|
||||
expect_failure "export to existing destination" \
|
||||
"$nocal" --export "$existing_destination" --calendar "$source_calendar"
|
||||
cmp "$existing_destination" "$tmpdir/existing-destination-before" >/dev/null ||
|
||||
fail "failed export changed its existing destination"
|
||||
[ ! -e "$existing_destination.bak" ] ||
|
||||
fail "failed export created a backup for its existing destination"
|
||||
|
||||
# Unsafe export sources fail without creating any destination or backup.
|
||||
for unsafe_source in "$missing_uid" "$duplicate_uid" "$unsupported"; do
|
||||
unsafe_name=$(basename "$unsafe_source" .ics)
|
||||
unsafe_destination=$tmpdir/export-$unsafe_name-output.ics
|
||||
expect_failure "$unsafe_name export" \
|
||||
"$nocal" --export "$unsafe_destination" --calendar "$unsafe_source"
|
||||
[ ! -e "$unsafe_destination" ] ||
|
||||
fail "$unsafe_name export created an output file"
|
||||
[ ! -e "$unsafe_destination.bak" ] ||
|
||||
fail "$unsafe_name export created an output backup"
|
||||
done
|
||||
|
||||
# Transfer actions are exclusive and cannot be combined with interactive,
|
||||
# print, demo, or recovery actions. These parse failures must occur without a
|
||||
# destination write or target backup.
|
||||
option_target=$tmpdir/option-target.ics
|
||||
write_target "$option_target"
|
||||
cp "$option_target" "$tmpdir/option-target-before.ics"
|
||||
option_export=$tmpdir/option-export.ics
|
||||
|
||||
expect_failure "combined import and export" \
|
||||
"$nocal" --import "$source_calendar" --export "$option_export" \
|
||||
--calendar "$option_target"
|
||||
|
||||
for conflicting_option in --demo --print --restore-backup; do
|
||||
expect_failure "import with $conflicting_option" \
|
||||
"$nocal" --import "$source_calendar" "$conflicting_option" \
|
||||
--calendar "$option_target"
|
||||
expect_failure "export with $conflicting_option" \
|
||||
"$nocal" --export "$option_export" "$conflicting_option" \
|
||||
--calendar "$option_target"
|
||||
done
|
||||
|
||||
cmp "$option_target" "$tmpdir/option-target-before.ics" >/dev/null ||
|
||||
fail "invalid transfer option combination changed the calendar"
|
||||
[ ! -e "$option_target.bak" ] ||
|
||||
fail "invalid transfer option combination created a calendar backup"
|
||||
[ ! -e "$option_export" ] ||
|
||||
fail "invalid transfer option combination created an export destination"
|
||||
Reference in New Issue
Block a user