Files
fcitx5-m3ui/tests/blockstore_test.cpp
justmeandClaude Sonnet 5.5 3bfbcb733a Keep the list of forgotten words safe, and keep the highlight where it was
The block list is now a BlockStore. The file is replaced atomically (temporary
file, fsync, rename), created readable by the owner only, picked up again when it
changes on disk, and a word whose save failed stays hidden in memory and is
written on the next attempt instead of being lost on restart. Words with line
breaks or NUL, over 256 bytes, and lists over 20000 entries are refused.

Removing candidates from the engine's list resets its cursor to the first
candidate when the removal is in front of it, so the highlight jumped while
arrowing through candidates. The filter now puts the cursor back on the
candidate it was on.

Both parts are in headers that the window and the tests include, so the tests
exercise the code that runs: tests/blockstore_test.cpp covers restarts, failed
writes, outside edits and bad input, tests/blockfilter_test.cpp runs the filter
on a real Fcitx5 candidate list.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 23:36:54 +08:00

123 lines
4.7 KiB
C++

// Checks how the list of forgotten words survives restarts, crashes and failed
// writes. g++ -std=c++20 -Isrc tests/blockstore_test.cpp -o /tmp/blockstore_test
#include <sys/stat.h>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <sstream>
#include <thread>
#include "blockstore.h"
using fcitx::m3ui::BlockStore;
namespace fs = std::filesystem;
static int failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); \
failures++; \
} \
} while (0)
static std::string slurp(const fs::path &p) {
std::ifstream in(p);
std::stringstream ss;
ss << in.rdbuf();
return ss.str();
}
int main() {
char tmpl[] = "/tmp/blockstore_test.XXXXXX";
const fs::path dir = mkdtemp(tmpl);
const fs::path file = dir / "m3ui" / "blocked-words.txt";
{ // words survive a restart, which is a new store on the same file
BlockStore first(file);
CHECK(first.empty());
CHECK(first.add("伟大实践"));
CHECK(first.add("你不能"));
CHECK(first.contains("你不能"));
BlockStore afterRestart(file);
CHECK(afterRestart.size() == 2);
CHECK(afterRestart.contains("伟大实践") && afterRestart.contains("你不能"));
CHECK(slurp(file) == "伟大实践\n你不能\n");
}
{ // the file is private and no temporary file is left behind
struct stat st {};
CHECK(::stat(file.c_str(), &st) == 0);
CHECK((st.st_mode & 0777) == 0600);
int stray = 0;
for (const auto &e : fs::directory_iterator(file.parent_path())) {
stray += e.path().filename() != "blocked-words.txt";
}
CHECK(stray == 0);
}
{ // duplicates are not written twice, bad words are refused
BlockStore s(file);
CHECK(s.add("你不能"));
CHECK(s.size() == 2);
CHECK(!s.add(""));
CHECK(!s.add("two\nlines"));
CHECK(!s.add(std::string("nul\0byte", 8)));
CHECK(!s.add(std::string(BlockStore::kMaxWordBytes + 1, 'x')));
CHECK(s.size() == 2);
CHECK(slurp(file) == "伟大实践\n你不能\n");
}
{ // a hand edited file with Windows line endings and blank lines still loads
const fs::path edited = dir / "edited.txt";
std::ofstream(edited, std::ios::binary) << "甲\r\n\r\n乙\r\n";
BlockStore s(edited);
CHECK(s.size() == 2 && s.contains("甲") && s.contains("乙"));
}
{ // a change made by someone else is noticed without a restart
BlockStore running(file);
CHECK(!running.contains("外部"));
std::this_thread::sleep_for(std::chrono::milliseconds(20));
{
std::ofstream out(file, std::ios::app);
out << "外部\n";
}
running.reloadIfChanged();
CHECK(running.contains("外部"));
// adding after that keeps the outside change, nothing is overwritten
CHECK(running.add("新词"));
CHECK(slurp(file).find("外部") != std::string::npos);
CHECK(slurp(file).find("新词") != std::string::npos);
}
{ // a failed save keeps the word active and is retried, not forgotten
const fs::path lockedDir = dir / "locked";
fs::create_directories(lockedDir);
const fs::path lockedFile = lockedDir / "blocked-words.txt";
BlockStore s(lockedFile);
fs::permissions(lockedDir, fs::perms::owner_read | fs::perms::owner_exec);
const bool saved = s.add("保留");
if (::geteuid() != 0) {
CHECK(!saved);
}
CHECK(s.contains("保留")); // still hidden for this session
fs::permissions(lockedDir, fs::perms::owner_all);
s.reloadIfChanged(); // the retry
CHECK(slurp(lockedFile) == "保留\n");
BlockStore restarted(lockedFile);
CHECK(restarted.contains("保留"));
}
{ // a file that vanishes while running is written again
BlockStore s(file);
const auto before = s.size();
fs::remove(file);
s.reloadIfChanged();
CHECK(s.size() == before);
CHECK(s.add("再来"));
BlockStore again(file);
CHECK(again.contains("再来") && again.contains("伟大实践"));
}
fs::remove_all(dir);
std::printf(failures ? "%d check(s) failed\n" : "blockstore: all checks passed\n", failures);
return failures ? 1 : 0;
}