Files
roro9stack/test/test_note_document/test_note_document.cpp
T
twislaandClaude Opus 5.5 de8af6ed92
CI / build (pull_request) Successful in 1m52s
Site / build (pull_request) Successful in 12s
Notes: edit a text file of any size (#47)
The editor held the whole note in memory and stopped at 16 KB. It now keeps
a window of the file around the cursor, and the rest on the card as a list
of pieces (notes::NoteDocument). Memory with a note open is what it was.

Up to 64 KB a save rewrites the file, as before. Above, the five-second
save appends what changed to <note>.edit, and the file is rewritten on
leaving the note, with a progress bar. After a power cut, opening the note
picks the edit up where it was saved; a rewrite cut short is finished or
dropped, never half applied.

Also: Ctrl with Fn+Up/Down go to the start and end of the note; the
consoles' `key` command takes ctrl-, alt- and shift-; the Storage App's
`e` no longer refuses a big file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 15:39:45 +02:00

522 lines
20 KiB
C++

#include <unity.h>
#include <cstdint>
#include <map>
#include <random>
#include <string>
#include "note_document.h"
using namespace roro::notes;
void setUp() {}
void tearDown() {}
namespace {
// A card in memory. `budget`: how many more bytes it takes before the power is cut: the write
// that crosses it is left half done, and nothing works after it.
struct FakeCard : NoteCard {
std::map<std::string, std::string> files;
int64_t budget = -1;
bool dead = false;
uint64_t free = 1ull << 30;
int reads = 0;
bool spend(size_t n) {
if (dead) return false;
if (budget < 0) return true;
if (static_cast<int64_t>(n) <= budget) return budget -= static_cast<int64_t>(n), true;
return false;
}
bool size(const std::string& path, uint32_t& size) override {
auto it = files.find(path);
if (dead || it == files.end()) return false;
size = static_cast<uint32_t>(it->second.size());
return true;
}
size_t read(const std::string& path, uint32_t at, uint8_t* into, size_t len) override {
auto it = files.find(path);
if (dead || it == files.end() || at > it->second.size()) return 0;
reads++;
size_t n = std::min(len, it->second.size() - at);
std::copy(it->second.begin() + at, it->second.begin() + at + static_cast<long>(n), into);
return n;
}
bool create(const std::string& path) override {
if (!spend(1)) return dead = true, false;
files[path].clear();
return true;
}
bool append(const std::string& path, const uint8_t* data, size_t len) override {
if (dead) return false;
if (!spend(len)) {
files[path].append(reinterpret_cast<const char*>(data), static_cast<size_t>(std::max<int64_t>(0, budget)));
return dead = true, false;
}
files[path].append(reinterpret_cast<const char*>(data), len);
return true;
}
bool remove(const std::string& path) override {
if (!spend(1)) return dead = true, false;
return files.erase(path) > 0;
}
bool rename(const std::string& from, const std::string& to) override {
if (!spend(1)) return dead = true, false;
if (!files.count(from) || files.count(to)) return false;
files[to] = files[from];
files.erase(from);
return true;
}
uint64_t freeBytes() override { return free; }
void powerBack() {
dead = false;
budget = -1;
}
};
constexpr int kCols = 38, kRows = 8;
const char* const kNote = "/notes/big.txt";
std::string lines(int from, int count) {
std::string s;
char b[32];
for (int i = 0; i < count; i++) {
std::snprintf(b, sizeof b, "line %06d\n", from + i);
s += b;
}
return s;
}
// Each line is 12 bytes: "line 000000\n".
constexpr uint32_t kLine = 12;
bool rewrite(NoteDocument& d) {
std::string why;
if (!d.rewriteStart(why)) return false;
for (int guard = 0; guard < 100000; guard++) {
int p = d.rewriteStep(why);
if (p == 100) return true;
if (p < 0) return false;
}
return false;
}
void settle(NoteDocument& d) {
std::string why;
if (d.wantsMove()) TEST_ASSERT_TRUE_MESSAGE(d.move(why), why.c_str());
}
void type(NoteDocument& d, const std::string& s) {
for (char c : s) {
TEST_ASSERT_TRUE(d.text().insert(static_cast<uint8_t>(c)));
settle(d);
}
}
std::string currentRow(NoteDocument& d) { return d.text().rows()[static_cast<size_t>(d.text().cursorRow())]; }
} // namespace
void test_a_small_note_is_all_window() {
FakeCard card;
card.files[kNote] = "Hello\nworld\n";
NoteDocument d(card, kCols, kRows);
TEST_ASSERT_EQUAL_STRING("", d.open(kNote).c_str());
TEST_ASSERT_FALSE(d.windowed());
TEST_ASSERT_FALSE(d.dirty());
TEST_ASSERT_TRUE(d.wantsRewrite());
type(d, "Oh! ");
TEST_ASSERT_TRUE(d.dirty());
TEST_ASSERT_TRUE(rewrite(d));
TEST_ASSERT_FALSE(d.dirty());
TEST_ASSERT_EQUAL_STRING("Oh! Hello\nworld\n", card.files[kNote].c_str());
TEST_ASSERT_EQUAL_size_t(1, card.files.size()); // no side file, no temporary file left
}
void test_a_new_note_has_no_file_until_it_is_rewritten() {
FakeCard card;
NoteDocument d(card, kCols, kRows);
type(d, "A list");
TEST_ASSERT_TRUE(d.dirty());
std::string why;
TEST_ASSERT_FALSE(d.rewriteStart(why));
TEST_ASSERT_FALSE(d.journal(why));
TEST_ASSERT_EQUAL_size_t(0, card.files.size());
d.setPath("/notes/a-list.txt");
TEST_ASSERT_TRUE(rewrite(d));
TEST_ASSERT_EQUAL_STRING("A list", card.files["/notes/a-list.txt"].c_str());
type(d, "!");
TEST_ASSERT_TRUE(rewrite(d));
TEST_ASSERT_EQUAL_STRING("A list!", card.files["/notes/a-list.txt"].c_str());
}
void test_a_big_file_opens_as_a_window() {
FakeCard card;
card.files[kNote] = lines(0, 20000); // 240 KB
NoteDocument d(card, kCols, kRows);
TEST_ASSERT_EQUAL_STRING("", d.open(kNote).c_str());
TEST_ASSERT_TRUE(d.windowed());
TEST_ASSERT_EQUAL_UINT32(240000, d.size());
TEST_ASSERT_TRUE(d.text().size() <= NoteText::kMaxBytes / 2 + 200);
TEST_ASSERT_EQUAL_STRING("line 000000", d.text().rows()[0].c_str());
TEST_ASSERT_FALSE(d.wantsRewrite());
TEST_ASSERT_FALSE(d.dirty());
}
void test_walking_down_and_back_up_through_windows() {
FakeCard card;
card.files[kNote] = lines(0, 5000);
NoteDocument d(card, kCols, kRows);
d.open(kNote);
for (int i = 1; i <= 3000; i++) {
d.text().down();
settle(d);
if (i % 500 == 0) {
TEST_ASSERT_EQUAL_UINT32(static_cast<uint32_t>(i) * kLine, d.cursor());
TEST_ASSERT_EQUAL_STRING(lines(i, 1).substr(0, 11).c_str(), currentRow(d).c_str());
TEST_ASSERT_EQUAL_INT(kRows - 1, d.text().cursorRow()); // the screen didn't jump when the window moved
}
}
for (int i = 2999; i >= 0; i--) {
d.text().up();
settle(d);
if (i % 500 == 0) {
TEST_ASSERT_EQUAL_UINT32(static_cast<uint32_t>(i) * kLine, d.cursor());
TEST_ASSERT_EQUAL_STRING(lines(i, 1).substr(0, 11).c_str(), currentRow(d).c_str());
}
}
TEST_ASSERT_FALSE(d.dirty()); // looking isn't editing
TEST_ASSERT_EQUAL_size_t(1, card.files.size()); // and writes nothing
}
void test_start_and_end() {
FakeCard card;
card.files[kNote] = lines(0, 5000) + "the end";
NoteDocument d(card, kCols, kRows);
d.open(kNote);
std::string why;
TEST_ASSERT_TRUE(d.jump(d.size(), why));
TEST_ASSERT_EQUAL_UINT32(d.size(), d.cursor());
TEST_ASSERT_EQUAL_STRING("the end", currentRow(d).c_str());
TEST_ASSERT_EQUAL_INT(kRows - 1, d.text().cursorRow());
TEST_ASSERT_EQUAL_INT(99, d.percent());
TEST_ASSERT_TRUE(d.jump(0, why));
TEST_ASSERT_EQUAL_UINT32(0, d.cursor());
TEST_ASSERT_EQUAL_STRING("line 000000", currentRow(d).c_str());
}
void test_an_edit_in_the_middle_is_rewritten_into_the_file() {
FakeCard card;
std::string was = lines(0, 20000);
card.files[kNote] = was;
NoteDocument d(card, kCols, kRows);
d.open(kNote);
std::string why;
TEST_ASSERT_TRUE(d.jump(120000, why));
type(d, "HERE ");
TEST_ASSERT_TRUE(d.jump(0, why)); // the changed window goes to the side file
TEST_ASSERT_TRUE(card.files.count(std::string(kNote) + ".edit"));
TEST_ASSERT_EQUAL_STRING(was.c_str(), card.files[kNote].c_str()); // the file isn't touched until the rewrite
type(d, "TOP ");
TEST_ASSERT_TRUE(d.filePending());
TEST_ASSERT_TRUE(rewrite(d));
std::string want = "TOP " + was.substr(0, 120000) + "HERE " + was.substr(120000);
TEST_ASSERT_TRUE(want == card.files[kNote]);
TEST_ASSERT_EQUAL_size_t(1, card.files.size());
TEST_ASSERT_FALSE(d.filePending());
TEST_ASSERT_FALSE(d.dirty());
// And it goes on from there.
TEST_ASSERT_TRUE(d.jump(d.size(), why));
type(d, "END");
TEST_ASSERT_TRUE(rewrite(d));
TEST_ASSERT_TRUE(want + "END" == card.files[kNote]);
}
void test_typing_never_fills_the_note() {
FakeCard card;
NoteDocument d(card, kCols, kRows);
d.setPath("/notes/long.txt");
std::string want;
for (int i = 0; i < 4000; i++) { // 48 KB, three windows' worth
std::string l = lines(i, 1);
for (char c : l) {
if (!d.text().insert(static_cast<uint8_t>(c))) TEST_FAIL_MESSAGE("the window was full");
if (d.wantsMove()) {
std::string why;
if (d.path().empty() || !d.windowed()) TEST_ASSERT_TRUE(rewrite(d)); // what the editor does: a file first
TEST_ASSERT_TRUE_MESSAGE(d.move(why), why.c_str());
}
}
want += l;
}
TEST_ASSERT_TRUE(d.windowed());
TEST_ASSERT_EQUAL_UINT32(48000, d.size());
TEST_ASSERT_TRUE(rewrite(d));
TEST_ASSERT_TRUE(want == card.files["/notes/long.txt"]);
}
void test_a_journal_is_picked_up_after_a_power_cut() {
FakeCard card;
std::string was = lines(0, 20000);
card.files[kNote] = was;
std::string why;
{
NoteDocument d(card, kCols, kRows);
d.open(kNote);
d.jump(60000, why);
type(d, "one ");
TEST_ASSERT_TRUE(d.journal(why));
TEST_ASSERT_FALSE(d.dirty());
TEST_ASSERT_TRUE(d.filePending());
d.jump(180000, why);
type(d, "two ");
TEST_ASSERT_TRUE(d.journal(why));
type(d, "never saved");
} // the power goes
TEST_ASSERT_EQUAL_STRING(was.c_str(), card.files[kNote].c_str());
NoteDocument d(card, kCols, kRows);
std::string told;
TEST_ASSERT_EQUAL_STRING("", d.open(kNote, &told).c_str());
TEST_ASSERT_EQUAL_STRING("Your unsaved changes are back", told.c_str());
TEST_ASSERT_EQUAL_UINT32(240008, d.size());
TEST_ASSERT_EQUAL_UINT32(180004, d.cursor()); // where it was
TEST_ASSERT_TRUE(d.filePending());
TEST_ASSERT_TRUE(rewrite(d));
TEST_ASSERT_TRUE(was.substr(0, 60000) + "one " + was.substr(60000, 119996) + "two " + was.substr(179996) == card.files[kNote]);
TEST_ASSERT_EQUAL_size_t(1, card.files.size());
}
void test_a_cut_at_any_byte_keeps_the_note_or_the_last_save() {
std::string was = lines(0, 9000); // 108 KB
std::string saved1 = was.substr(0, 50000) + "first " + was.substr(50000);
std::string saved2 = "second " + saved1;
int outcomes[3] = {0, 0, 0};
for (int64_t budget = 0; budget < 240000; budget += 997) {
FakeCard card;
card.files[kNote] = was;
std::string why;
bool journaled1 = false, journaled2 = false, rewritten = false;
{
NoteDocument d(card, kCols, kRows);
d.open(kNote);
card.budget = budget;
d.jump(50000, why);
for (char c : std::string("first ")) d.text().insert(static_cast<uint8_t>(c));
journaled1 = d.journal(why);
if (journaled1 && d.jump(0, why)) {
for (char c : std::string("second ")) d.text().insert(static_cast<uint8_t>(c));
journaled2 = d.journal(why);
if (journaled2) rewritten = rewrite(d);
}
}
card.powerBack();
// What the Notes list does with a temporary file that has lost its note.
if (!card.files.count(kNote) && card.files.count(std::string(kNote) + ".tmp")) card.rename(std::string(kNote) + ".tmp", kNote);
NoteDocument d(card, kCols, kRows);
TEST_ASSERT_EQUAL_STRING("", d.open(kNote).c_str());
TEST_ASSERT_TRUE(rewrite(d) || !d.dirty());
if (d.filePending()) TEST_ASSERT_TRUE(rewrite(d));
const std::string& now = card.files[kNote];
int which = now == was ? 0 : now == saved1 ? 1 : now == saved2 ? 2 : -1;
char msg[64];
std::snprintf(msg, sizeof msg, "budget %ld", static_cast<long>(budget));
TEST_ASSERT_TRUE_MESSAGE(which >= 0, msg); // never anything else
if (rewritten) TEST_ASSERT_EQUAL_INT_MESSAGE(2, which, msg); // what was said to be saved is there
else if (journaled2) TEST_ASSERT_EQUAL_INT_MESSAGE(2, which, msg);
else if (journaled1) TEST_ASSERT_TRUE_MESSAGE(which >= 1, msg);
TEST_ASSERT_EQUAL_size_t_MESSAGE(1, card.files.size(), msg); // and nothing is left lying about
outcomes[which]++;
}
TEST_ASSERT_TRUE(outcomes[0] > 0 && outcomes[1] > 0 && outcomes[2] > 0);
}
void test_a_file_changed_elsewhere_sets_the_edits_aside() {
FakeCard card;
card.files[kNote] = lines(0, 20000);
std::string why;
{
NoteDocument d(card, kCols, kRows);
d.open(kNote);
d.jump(60000, why);
type(d, "typed ");
TEST_ASSERT_TRUE(d.journal(why));
}
card.files[kNote] = lines(7, 100); // someone put another file there
NoteDocument d(card, kCols, kRows);
std::string told;
TEST_ASSERT_EQUAL_STRING("", d.open(kNote, &told).c_str());
TEST_ASSERT_TRUE(told.find(".edit.lost") != std::string::npos);
TEST_ASSERT_TRUE(card.files.count(std::string(kNote) + ".edit.lost"));
TEST_ASSERT_FALSE(card.files.count(std::string(kNote) + ".edit"));
TEST_ASSERT_TRUE(card.files[std::string(kNote) + ".edit.lost"].find("typed ") != std::string::npos); // it can be read
TEST_ASSERT_EQUAL_UINT32(1200, d.size());
TEST_ASSERT_FALSE(d.filePending());
}
void test_crlf_becomes_lf_everywhere() {
FakeCard card;
std::string crlf, lf;
for (int i = 0; i < 6000; i++) {
std::string l = lines(i, 1);
lf += l;
crlf += l.substr(0, 11) + "\r\n";
}
card.files[kNote] = crlf;
NoteDocument d(card, kCols, kRows);
d.open(kNote);
for (int i = 1; i <= 1500; i++) { // through several windows, looking only
d.text().down();
settle(d);
}
TEST_ASSERT_EQUAL_STRING("line 001500", currentRow(d).c_str());
TEST_ASSERT_EQUAL_INT(0, d.text().cursorCol());
type(d, "x");
TEST_ASSERT_TRUE(rewrite(d));
std::string want = lf;
want.insert(1500 * kLine, "x");
TEST_ASSERT_TRUE(want == card.files[kNote]);
}
void test_windows_never_cut_a_character() {
FakeCard card;
std::string text;
while (text.size() < 80000) text += "\xC3\xA9\xE2\x82\xAC\xF0\x9F\x98\x80"; // é, €, a face: no space, no newline
card.files[kNote] = text;
NoteDocument d(card, kCols, kRows);
d.open(kNote);
std::string why;
for (uint32_t to : {30001u, 12345u, 79990u, 40003u, 5u}) {
TEST_ASSERT_TRUE(d.jump(to, why));
const std::string& w = d.text().text();
TEST_ASSERT_TRUE((static_cast<uint8_t>(w[0]) & 0xC0) != 0x80);
for (size_t i = 0; i < w.size();) { // whole characters, to the last
uint8_t c = static_cast<uint8_t>(w[i]);
size_t n = c >= 0xF0 ? 4 : c >= 0xE0 ? 3 : c >= 0xC0 ? 2 : 1;
TEST_ASSERT_TRUE(c < 0x80 || c >= 0xC0);
TEST_ASSERT_TRUE(i + n <= w.size());
i += n;
}
TEST_ASSERT_TRUE((static_cast<uint8_t>(w[d.text().cursor()]) & 0xC0) != 0x80 || d.text().cursor() == w.size());
type(d, "a");
}
TEST_ASSERT_TRUE(rewrite(d));
TEST_ASSERT_EQUAL_size_t(text.size() + 5, card.files[kNote].size());
}
void test_random_edits_match_a_plain_string() {
std::mt19937 rng(47);
for (int round = 0; round < 6; round++) {
FakeCard card;
std::string model;
while (model.size() < 30000u + 20000u * static_cast<unsigned>(round)) {
int words = 1 + static_cast<int>(rng() % 30);
for (int w = 0; w < words; w++) model += std::string(1 + rng() % 9, static_cast<char>('a' + rng() % 26)) + " ";
model += rng() % 4 ? "\n" : "\n\n";
}
card.files[kNote] = model;
std::string saved = model; // what a power cut would leave
NoteDocument* d = new NoteDocument(card, kCols, kRows);
d->open(kNote);
std::string why;
for (int step = 0; step < 6000; step++) {
unsigned op = rng() % 100;
NoteText& t = d->text();
if (op < 40) {
char c = rng() % 8 ? static_cast<char>('A' + rng() % 26) : '\n';
uint32_t at = d->cursor();
TEST_ASSERT_TRUE(t.insert(static_cast<uint8_t>(c)));
model.insert(at, 1, c);
} else if (op < 50) {
uint32_t at = d->cursor();
t.backspace();
if (at > 0 && d->cursor() == at - 1) model.erase(at - 1, 1);
else TEST_ASSERT_EQUAL_UINT32(at, d->cursor());
} else if (op < 60) t.left();
else if (op < 70) t.right();
else if (op < 78) t.up();
else if (op < 86) t.down();
else if (op < 90) t.pageUp();
else if (op < 94) t.pageDown();
else if (op < 96) TEST_ASSERT_TRUE(d->jump(rng() % (d->size() + 1), why));
else if (op < 98) {
if (d->dirty()) {
TEST_ASSERT_TRUE(d->wantsRewrite() ? rewrite(*d) : d->journal(why));
saved = model;
}
} else if (op == 98) {
TEST_ASSERT_TRUE(rewrite(*d));
saved = model;
TEST_ASSERT_TRUE(model == card.files[kNote]);
} else { // the power goes, and comes back
delete d;
d = new NoteDocument(card, kCols, kRows);
TEST_ASSERT_EQUAL_STRING("", d->open(kNote).c_str());
model = saved;
}
settle(*d);
TEST_ASSERT_EQUAL_UINT32(static_cast<uint32_t>(model.size()), d->size());
// The window is the note's text at its place.
TEST_ASSERT_TRUE(d->cursor() <= model.size());
uint32_t start = d->cursor() - static_cast<uint32_t>(d->text().cursor());
TEST_ASSERT_TRUE(model.compare(start, d->text().size(), d->text().text()) == 0);
}
TEST_ASSERT_TRUE(rewrite(*d));
TEST_ASSERT_TRUE(model == card.files[kNote]);
TEST_ASSERT_EQUAL_size_t(1, card.files.size());
delete d;
}
}
void test_a_full_card_and_a_missing_file() {
FakeCard card;
card.files[kNote] = lines(0, 20000);
card.free = 1000;
NoteDocument d(card, kCols, kRows);
TEST_ASSERT_TRUE(d.open(kNote).find("Not enough room") != std::string::npos);
TEST_ASSERT_TRUE(d.open("/notes/none.txt").find("refused") != std::string::npos);
card.files["/notes/small.txt"] = "small";
TEST_ASSERT_EQUAL_STRING("", d.open("/notes/small.txt").c_str()); // a small one still opens
type(d, "x");
std::string why;
TEST_ASSERT_FALSE(d.rewriteStart(why));
TEST_ASSERT_EQUAL_STRING("the card is full", why.c_str());
TEST_ASSERT_TRUE(d.dirty());
}
void test_the_side_file_and_the_piece_list_ask_for_a_rewrite() {
FakeCard card;
card.files[kNote] = lines(0, 20000);
NoteDocument d(card, kCols, kRows);
d.open(kNote);
std::string why;
int saves = 0;
while (!d.wantsRewrite() && saves < 1000) {
type(d, "x");
TEST_ASSERT_TRUE(d.journal(why));
saves++;
}
TEST_ASSERT_TRUE(saves > 50 && saves < 300); // about 1 MB of 8 KB windows
TEST_ASSERT_TRUE(rewrite(d));
TEST_ASSERT_FALSE(d.wantsRewrite());
TEST_ASSERT_EQUAL_size_t(240000 + static_cast<size_t>(saves), card.files[kNote].size());
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_a_small_note_is_all_window);
RUN_TEST(test_a_new_note_has_no_file_until_it_is_rewritten);
RUN_TEST(test_a_big_file_opens_as_a_window);
RUN_TEST(test_walking_down_and_back_up_through_windows);
RUN_TEST(test_start_and_end);
RUN_TEST(test_an_edit_in_the_middle_is_rewritten_into_the_file);
RUN_TEST(test_typing_never_fills_the_note);
RUN_TEST(test_a_journal_is_picked_up_after_a_power_cut);
RUN_TEST(test_a_cut_at_any_byte_keeps_the_note_or_the_last_save);
RUN_TEST(test_a_file_changed_elsewhere_sets_the_edits_aside);
RUN_TEST(test_crlf_becomes_lf_everywhere);
RUN_TEST(test_windows_never_cut_a_character);
RUN_TEST(test_random_edits_match_a_plain_string);
RUN_TEST(test_a_full_card_and_a_missing_file);
RUN_TEST(test_the_side_file_and_the_piece_list_ask_for_a_rewrite);
return UNITY_END();
}