#include #include #include #include #include #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 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(n) <= budget) return budget -= static_cast(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(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(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(data), static_cast(std::max(0, budget))); return dead = true, false; } files[path].append(reinterpret_cast(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(c))); settle(d); } } std::string currentRow(NoteDocument& d) { return d.text().rows()[static_cast(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(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(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(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(c)); journaled1 = d.journal(why); if (journaled1 && d.jump(0, why)) { for (char c : std::string("second ")) d.text().insert(static_cast(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(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(w[0]) & 0xC0) != 0x80); for (size_t i = 0; i < w.size();) { // whole characters, to the last uint8_t c = static_cast(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(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(round)) { int words = 1 + static_cast(rng() % 30); for (int w = 0; w < words; w++) model += std::string(1 + rng() % 9, static_cast('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('A' + rng() % 26) : '\n'; uint32_t at = d->cursor(); TEST_ASSERT_TRUE(t.insert(static_cast(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(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(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(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(); }