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>
This commit is contained in:
1 parent
ce0d6ba9ae
commit
3bfbcb733a
7 files changed
+484
-39
No files matched your search
@@ -0,0 +1,116 @@
|
||||
// Runs the real filter on a real Fcitx5 candidate list.
|
||||
// g++ -std=c++20 -Isrc tests/blockfilter_test.cpp -o /tmp/blockfilter_test
|
||||
// (add the flags from: pkg-config --cflags --libs Fcitx5Core)
|
||||
#include <cstdio>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#include "blockfilter.h"
|
||||
#include "fcitx/candidatelist.h"
|
||||
#include "fcitx/text.h"
|
||||
|
||||
using namespace fcitx;
|
||||
|
||||
static int failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); \
|
||||
failures++; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static std::unique_ptr<CommonCandidateList> makeList(const std::vector<std::string> &words, int pageSize) {
|
||||
auto list = std::make_unique<CommonCandidateList>();
|
||||
list->setPageSize(pageSize);
|
||||
for (const auto &w : words) {
|
||||
list->append<DisplayOnlyCandidateWord>(Text(w));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
static std::vector<std::string> shown(CommonCandidateList &list) {
|
||||
std::vector<std::string> out;
|
||||
for (int i = 0; i < list.totalSize(); i++) {
|
||||
out.push_back(list.candidateFromAll(i).text().toString());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
int main() {
|
||||
const std::unordered_set<std::string> blocked = {"我当时就", "你不能"};
|
||||
auto isBlocked = [&](const std::string &w) { return blocked.contains(w); };
|
||||
|
||||
{ // the filter is reachable through the interface the window uses
|
||||
auto list = makeList({"我", "我当时就", "你不能", "你"}, 5);
|
||||
CandidateList *asBase = list.get();
|
||||
CHECK(asBase->toModifiable() != nullptr);
|
||||
CHECK(fcitx::m3ui::removeBlockedCandidates(*asBase, isBlocked) == 2);
|
||||
CHECK((shown(*list) == std::vector<std::string>{"我", "你"}));
|
||||
}
|
||||
{ // nothing blocked, nothing touched
|
||||
auto list = makeList({"a", "b", "c"}, 5);
|
||||
CHECK(fcitx::m3ui::removeBlockedCandidates(*list, isBlocked) == 0);
|
||||
CHECK(list->totalSize() == 3);
|
||||
}
|
||||
{ // every candidate blocked leaves an empty but valid list
|
||||
auto list = makeList({"我当时就", "你不能"}, 5);
|
||||
CHECK(fcitx::m3ui::removeBlockedCandidates(*list, isBlocked) == 2);
|
||||
CHECK(list->totalSize() == 0);
|
||||
CHECK(list->size() == 0);
|
||||
}
|
||||
{ // the same word more than once is removed everywhere
|
||||
auto list = makeList({"你不能", "好", "你不能", "你不能"}, 5);
|
||||
CHECK(fcitx::m3ui::removeBlockedCandidates(*list, isBlocked) == 3);
|
||||
CHECK((shown(*list) == std::vector<std::string>{"好"}));
|
||||
}
|
||||
{ // over several pages the page contents and labels stay consistent
|
||||
std::vector<std::string> words;
|
||||
for (int i = 0; i < 12; i++) {
|
||||
words.push_back(i % 4 == 1 ? "你不能" : "词" + std::to_string(i));
|
||||
}
|
||||
auto list = makeList(words, 5);
|
||||
const int before = list->totalSize();
|
||||
const int removed = fcitx::m3ui::removeBlockedCandidates(*list, isBlocked);
|
||||
CHECK(removed == 3 && list->totalSize() == before - removed);
|
||||
for (int i = 0; i < list->totalSize(); i++) {
|
||||
CHECK(list->candidateFromAll(i).text().toString() != "你不能");
|
||||
}
|
||||
// the first page is full again, with candidates from the remaining ones
|
||||
CHECK(list->size() == 5);
|
||||
CHECK(list->totalPages() == (list->totalSize() + 4) / 5);
|
||||
CHECK(list->candidate(0).text().toString() == "词0");
|
||||
}
|
||||
{ // the highlight stays on the same candidate wherever the removal was
|
||||
for (int cursor = 0; cursor < 5; cursor++) {
|
||||
auto list = makeList({"a", "你不能", "b", "我当时就", "c"}, 5);
|
||||
list->setGlobalCursorIndex(cursor);
|
||||
const std::string before = list->candidateFromAll(cursor).text().toString();
|
||||
fcitx::m3ui::removeBlockedCandidates(*list, isBlocked);
|
||||
const std::string after = list->candidateFromAll(list->globalCursorIndex()).text().toString();
|
||||
if (before == "你不能" || before == "我当时就") {
|
||||
// the one under the cursor is gone, the cursor is still valid
|
||||
CHECK(list->globalCursorIndex() >= 0 && list->globalCursorIndex() < list->totalSize());
|
||||
} else {
|
||||
CHECK(after == before);
|
||||
}
|
||||
}
|
||||
}
|
||||
{ // removing the last candidate while the cursor is on it
|
||||
auto list = makeList({"a", "b", "你不能"}, 5);
|
||||
list->setGlobalCursorIndex(2);
|
||||
fcitx::m3ui::removeBlockedCandidates(*list, isBlocked);
|
||||
CHECK(list->globalCursorIndex() == 1);
|
||||
}
|
||||
{ // a list the cursor does not apply to is still filtered
|
||||
DisplayOnlyCandidateList list;
|
||||
list.setLayoutHint(CandidateLayoutHint::Vertical);
|
||||
CHECK(list.toModifiable() == nullptr); // cannot be modified, left alone
|
||||
CHECK(fcitx::m3ui::removeBlockedCandidates(list, isBlocked) == 0);
|
||||
}
|
||||
|
||||
std::printf(failures ? "%d check(s) failed\n" : "blockfilter: all checks passed\n", failures);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in new issue
Block a user