Files
EVEngine/test/filesystem_cpp.cpp
T

566 lines
18 KiB
C++

#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "common/Exception.h"
#include "data/ByteData.h"
#include "filesystem/Filesystem.h"
#include "filesystem/File.h"
#include "filesystem/FileData.h"
#include "filesystem/FileWatch.h"
#include <chrono>
#include <cstdint>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <memory>
#include <string>
#include <thread>
#include <vector>
namespace {
eve::filesystem::Filesystem* fs() {
return eve::filesystem::Filesystem::create();
}
// Unique identity per case to avoid cross-talk on the singleton write dir.
void useIdentity(const char* id) {
auto* f = fs();
REQUIRE(f->setIdentity(id, true));
REQUIRE(f->setupWriteDirectory());
}
// Bit-by-bit CRC-32 (zip polynomial); payloads in these tests are tiny so
// this is fine without pulling in a zlib dependency for the test binary.
uint32_t crc32Of(const void* data, size_t size) {
const auto* bytes = static_cast<const unsigned char*>(data);
uint32_t crc = 0xFFFFFFFFu;
for (size_t i = 0; i < size; ++i) {
crc ^= bytes[i];
for (int bit = 0; bit < 8; ++bit)
crc = (crc & 1u) ? (crc >> 1) ^ 0xEDB88320u : (crc >> 1);
}
return ~crc;
}
void put16(std::vector<unsigned char>& out, uint16_t v) {
out.push_back(static_cast<unsigned char>(v & 0xFF));
out.push_back(static_cast<unsigned char>((v >> 8) & 0xFF));
}
void put32(std::vector<unsigned char>& out, uint32_t v) {
for (int i = 0; i < 4; ++i) out.push_back(static_cast<unsigned char>((v >> (8 * i)) & 0xFF));
}
// Hand-rolled minimal single-entry, uncompressed (stored) ZIP archive, so
// filesystem.mount(Data*, ...) can be exercised without a real archive file
// or an extra zip-writer dependency in the test binary.
std::vector<unsigned char> buildMinimalZip(const std::string& entryName, const std::string& content) {
std::vector<unsigned char> out;
const uint32_t crc = crc32Of(content.data(), content.size());
const uint32_t localOffset = 0;
// Local file header.
put32(out, 0x04034b50u);
put16(out, 20); // version needed
put16(out, 0); // flags
put16(out, 0); // compression: stored
put16(out, 0); // mod time
put16(out, 0); // mod date
put32(out, crc);
put32(out, static_cast<uint32_t>(content.size())); // compressed size
put32(out, static_cast<uint32_t>(content.size())); // uncompressed size
put16(out, static_cast<uint16_t>(entryName.size()));
put16(out, 0); // extra field length
out.insert(out.end(), entryName.begin(), entryName.end());
out.insert(out.end(), content.begin(), content.end());
const size_t centralDirOffset = out.size();
// Central directory file header.
put32(out, 0x02014b50u);
put16(out, 20); // version made by
put16(out, 20); // version needed
put16(out, 0); // flags
put16(out, 0); // compression: stored
put16(out, 0); // mod time
put16(out, 0); // mod date
put32(out, crc);
put32(out, static_cast<uint32_t>(content.size()));
put32(out, static_cast<uint32_t>(content.size()));
put16(out, static_cast<uint16_t>(entryName.size()));
put16(out, 0); // extra field length
put16(out, 0); // comment length
put16(out, 0); // disk number start
put16(out, 0); // internal attributes
put32(out, 0); // external attributes
put32(out, localOffset);
out.insert(out.end(), entryName.begin(), entryName.end());
const size_t centralDirSize = out.size() - centralDirOffset;
// End of central directory record.
put32(out, 0x06054b50u);
put16(out, 0); // disk number
put16(out, 0); // disk with central dir
put16(out, 1); // entries on this disk
put16(out, 1); // total entries
put32(out, static_cast<uint32_t>(centralDirSize));
put32(out, static_cast<uint32_t>(centralDirOffset));
put16(out, 0); // comment length
return out;
}
} // namespace
TEST_CASE("filesystem.identityAndPaths") {
useIdentity("ev_ut_fs_paths");
auto* f = fs();
CHECK_EQ(f->getIdentity(), std::string("ev_ut_fs_paths"));
CHECK(!f->getWorkingDirectory().empty());
CHECK(!f->getUserDirectory().empty());
CHECK(!f->getAppdataDirectory().empty());
CHECK(!f->getSaveDirectory().empty());
CHECK(!f->getExecutablePath().empty());
f->setFused(true);
CHECK(f->isFused());
// fusedSet is sticky — second call ignored; still true
f->setFused(false);
CHECK(f->isFused());
f->setSymlinksEnabled(false);
CHECK(!f->areSymlinksEnabled());
f->setSymlinksEnabled(true);
CHECK(f->areSymlinksEnabled());
auto& req = f->getRequirePath();
CHECK_GE(req.size(), 0u);
(void)f->getCRequirePath();
// Android-only APIs: exercise default getters/setters on desktop
f->setAndroidSaveExternal(true);
CHECK(f->isAndroidSaveExternal());
f->setAndroidSaveExternal(false);
CHECK(!f->isAndroidSaveExternal());
}
TEST_CASE("filesystem.writeReadAppendRemove") {
useIdentity("ev_ut_fs_rw");
auto* f = fs();
const char* name = "ut_rw.txt";
const char* payload = "hello-fs";
f->write(name, payload, static_cast<int64_t>(std::strlen(payload)));
std::unique_ptr<eve::filesystem::FileData> data(f->read(name));
REQUIRE(data.get() != nullptr);
CHECK_EQ(data->getSize(), std::strlen(payload));
CHECK(std::memcmp(data->getData(), payload, data->getSize()) == 0);
const char* more = "!";
f->append(name, more, 1);
std::unique_ptr<eve::filesystem::FileData> data2(f->read(name));
REQUIRE(data2.get() != nullptr);
CHECK_EQ(data2->getSize(), std::strlen(payload) + 1);
eve::filesystem::Filesystem::Info fileInfo{};
REQUIRE(f->getInfo(name, fileInfo));
CHECK_EQ(fileInfo.type, std::string("file"));
CHECK_GE(fileInfo.size, 0);
CHECK(f->createDirectory("ut_subdir"));
auto items = f->getDirectoryItems("");
bool found = false;
for (const auto& it : items) {
if (it == name || it == "ut_subdir" || it == "ut_subdir/") found = true;
}
CHECK(found);
CHECK(f->remove(name));
CHECK(!f->getInfo(name, fileInfo));
bool threw = false;
try {
f->read("definitely_missing_ut_file_zzz.dat");
} catch (const eve::Exception&) {
threw = true;
}
CHECK(threw);
}
TEST_CASE("filesystem.atomicTextReplacePreservesContract") {
useIdentity("ev_ut_fs_atomic_text");
auto* f = fs();
const std::string name = "slot1.sav";
REQUIRE(f->writeTextAtomic(name, "version=1").ok());
CHECK_EQ(f->readText(name), std::string("version=1"));
REQUIRE(f->writeTextAtomic(name, "version=2\n角色=勇者").ok());
CHECK_EQ(f->readText(name), std::string("version=2\n角色=勇者"));
auto rejected = f->writeTextAtomic("../outside.sav", "invalid");
CHECK(!rejected.ok());
CHECK_EQ(f->readText(name), std::string("version=2\n角色=勇者"));
bool sawTemporary = false;
for (const auto& item : f->getDirectoryItems("")) {
if (item.find("slot1.sav.tmp.") != std::string::npos) sawTemporary = true;
}
CHECK(!sawTemporary);
CHECK(f->remove(name));
}
TEST_CASE("filesystem.setSourceSecondCallFails") {
auto* f = fs();
// `game_source` is a sticky, process-wide singleton field: only the very
// first call across the whole test binary can succeed. We are the only
// TEST_CASE that calls setSource(), so this exercises the happy path.
std::string cwd = f->getWorkingDirectory();
bool firstOk = f->setSource(cwd);
if (firstOk) {
CHECK_EQ(f->getSource(), cwd);
// getSourceBaseDirectory() strips the last path component of the source.
std::string base = f->getSourceBaseDirectory();
CHECK(!base.empty());
CHECK(cwd.rfind(base, 0) == 0);
} else {
// Defensive fallback in case some earlier test already set the source.
CHECK(!f->getSource().empty());
}
// Second call (regardless of which path above ran) must always fail.
CHECK(!f->setSource("somewhere_else"));
}
TEST_CASE("filesystem.File.openReadWriteSeek") {
useIdentity("ev_ut_fs_file");
auto* f = fs();
const char* name = "ev_sub/ut_file_api.bin";
CHECK(f->createDirectory("ev_sub"));
f->write(name, "0123456789", 10);
std::unique_ptr<eve::filesystem::FileData> viaFs(f->read(name));
REQUIRE(viaFs.get() != nullptr);
CHECK_EQ(viaFs->getSize(), 10u);
CHECK(std::memcmp(viaFs->getData(), "0123456789", 10) == 0);
std::unique_ptr<eve::filesystem::File> file(f->newFile(name));
REQUIRE(file.get() != nullptr);
CHECK_EQ(file->getFilename(), std::string(name));
CHECK(file->open("rb"));
CHECK(file->isOpen());
CHECK_EQ(file->getMode(), std::string("rb"));
CHECK_EQ(file->getSize(), 10);
CHECK_EQ(file->tell(), 0);
char buf[4] = {};
// CHECK_EQ evaluates lhs twice (compare + print) — do not put side-effecting reads there.
const int64_t nRead = file->read(buf, 4);
CHECK_EQ(nRead, 4);
CHECK(std::memcmp(buf, "0123", 4) == 0);
CHECK(file->seek(0));
CHECK_EQ(file->tell(), 0);
std::unique_ptr<eve::filesystem::FileData> head(file->read(4));
REQUIRE(head.get() != nullptr);
CHECK_EQ(head->getSize(), 4u);
CHECK(std::memcmp(head->getData(), "0123", 4) == 0);
CHECK(file->seek(0));
CHECK_EQ(file->tell(), 0);
std::unique_ptr<eve::filesystem::FileData> chunk(file->read(2));
REQUIRE(chunk.get() != nullptr);
CHECK_EQ(chunk->getSize(), 2u);
CHECK(std::memcmp(chunk->getData(), "01", 2) == 0);
// Love-style "r" alias must open for reading.
CHECK(file->close());
CHECK(file->open("r"));
CHECK_EQ(file->getMode(), std::string("rb"));
CHECK(file->close());
CHECK(!file->isOpen());
bool badMode = false;
try {
std::unique_ptr<eve::filesystem::File> f2(f->newFile(name));
if (!f2->open("not-a-mode"))
badMode = true;
} catch (const eve::Exception&) {
badMode = true;
}
CHECK(badMode);
f->remove(name);
}
TEST_CASE("filesystem.FileData.metaAndClone") {
useIdentity("ev_ut_fs_filedata");
auto* f = fs();
const char raw[] = "meta";
std::unique_ptr<eve::filesystem::FileData> fd(
f->newFileData(raw, "folder/shot.png", 4));
REQUIRE(fd.get() != nullptr);
CHECK_EQ(fd->getSize(), 4u);
CHECK(std::memcmp(fd->getData(), raw, 4) == 0);
CHECK_EQ(fd->getFilename(), std::string("folder/shot.png"));
CHECK_EQ(fd->getExtension(), std::string("png"));
CHECK_EQ(fd->getName(), std::string("folder/shot"));
std::unique_ptr<eve::filesystem::FileData> cloned(fd->clone());
CHECK_EQ(cloned->getExtension(), std::string("png"));
}
TEST_CASE("filesystem.mount.missingReturnsFalse") {
auto* f = fs();
CHECK(!f->mount("Z:/evengine_definitely_missing_archive_xxx.zip", "/m", false));
CHECK(!f->unmount("Z:/evengine_definitely_missing_archive_xxx.zip"));
}
TEST_CASE("filesystem.mount.realDirRoundTrip") {
useIdentity("ev_ut_fs_mount");
auto* f = fs();
f->write("mounted_marker.txt", "M", 1);
std::string realSave = f->getSaveDirectory();
f->allowMountingForPath(realSave);
REQUIRE(f->isRealDirectory(realSave));
REQUIRE(f->mount(realSave, "/mnt", false));
CHECK(f->unmount(realSave));
f->remove("mounted_marker.txt");
}
TEST_CASE("filesystem.mount.memoryDataRoundTrip") {
useIdentity("ev_ut_fs_mount_mem");
auto* f = fs();
const std::string content = "hi-from-memory-zip";
auto zipBytes = buildMinimalZip("hello.txt", content);
eve::data::ByteData zipData(zipBytes.data(), zipBytes.size());
REQUIRE(f->mount(&zipData, "ut_mem.zip", "/memzip", false));
eve::filesystem::Filesystem::Info zipEntryInfo{};
REQUIRE(f->getInfo("/memzip/hello.txt", zipEntryInfo));
CHECK_EQ(zipEntryInfo.type, std::string("file"));
std::unique_ptr<eve::filesystem::FileData> read(f->read("/memzip/hello.txt"));
REQUIRE(read.get() != nullptr);
CHECK_EQ(read->getSize(), content.size());
CHECK(std::memcmp(read->getData(), content.data(), content.size()) == 0);
CHECK(f->unmount(&zipData));
// Once unmounted, the mounted content should no longer resolve.
CHECK(!f->getInfo("/memzip/hello.txt", zipEntryInfo));
}
TEST_CASE("filesystem.getRealDirectory") {
useIdentity("ev_ut_fs_realdir");
auto* f = fs();
f->write("ut_realdir_marker.txt", "x", 1);
std::string real = f->getRealDirectory("ut_realdir_marker.txt");
CHECK(!real.empty());
CHECK_EQ(real, f->getSaveDirectory());
bool threw = false;
try {
f->getRealDirectory("ut_realdir_definitely_missing_zzz.dat");
} catch (const eve::Exception&) {
threw = true;
}
CHECK(threw);
f->remove("ut_realdir_marker.txt");
}
TEST_CASE("filesystem.File.extensionAndEof") {
useIdentity("ev_ut_fs_ext_eof");
auto* f = fs();
std::unique_ptr<eve::filesystem::File> withExt(f->newFile("dir/archive.tar.gz"));
CHECK_EQ(withExt->getExtension(), std::string("gz"));
std::unique_ptr<eve::filesystem::File> noExt(f->newFile("dir/README"));
CHECK_EQ(noExt->getExtension(), std::string());
const char* name = "ut_eof.bin";
f->write(name, "ab", 2);
std::unique_ptr<eve::filesystem::File> file(f->newFile(name));
// Not yet open: contract treats an unopened file as EOF.
CHECK(file->isEOF());
REQUIRE(file->open("rb"));
CHECK(!file->isEOF());
char buf[2] = {};
CHECK_EQ(file->read(buf, 2), 2);
CHECK(file->isEOF());
CHECK(file->close());
f->remove(name);
}
TEST_CASE("filesystem.File.bufferAndFlush") {
useIdentity("ev_ut_fs_buf");
auto* f = fs();
const char* name = "ut_buf.txt";
std::unique_ptr<eve::filesystem::File> file(f->newFile(name));
CHECK(file->setBuffer("full", 4096));
REQUIRE(file->open("wb"));
int64_t sz = 0;
CHECK_EQ(file->getBuffer(sz), std::string("full"));
CHECK_EQ(sz, 4096);
CHECK(file->write("x", 1));
CHECK(file->flush());
CHECK(file->close());
f->remove(name);
}
TEST_CASE("filesystem.watch.fileModified") {
useIdentity("ev_ut_fs_watch");
auto* f = fs();
f->unwatchAll();
const char* name = "ut_watch.txt";
f->write(name, "v1", 2);
REQUIRE(f->watch(name));
CHECK_EQ(f->getWatchCount(), 1);
// Darwin DirectoryWatcher baselines via periodic scan; wait one interval.
std::this_thread::sleep_for(std::chrono::milliseconds(1200));
while (!f->pollWatch().empty()) {
}
// Prefer OS write through the resolved save path so notifications fire reliably.
std::string real = f->getSaveDirectory() + "/" + name;
{
std::ofstream out(real, std::ios::binary | std::ios::trunc);
REQUIRE(out.good());
out.write("v2-changed", 10);
out.flush();
}
bool saw = false;
for (int i = 0; i < 60 && !saw; ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
for (;;) {
std::string kind = f->pollWatch();
if (kind.empty()) break;
if (kind == "modified" || kind == "added") {
CHECK_EQ(f->getLastWatchPath(), std::string(name));
saw = true;
}
}
}
CHECK(saw);
CHECK(f->unwatch(name));
CHECK_EQ(f->getWatchCount(), 0);
f->remove(name);
}
TEST_CASE("filesystem.watch.coalescesOverlappingRegistrations") {
const char *name = "ut_watch_coalesce.txt";
const auto dir = std::filesystem::temp_directory_path() / "ev_ut_fs_watch_coalesce";
std::filesystem::remove_all(dir);
REQUIRE(std::filesystem::create_directories(dir));
{
std::ofstream out(dir / name, std::ios::binary | std::ios::trunc);
REQUIRE(out.good());
out.write("v1", 2);
}
eve::filesystem::FileWatch watch;
REQUIRE(watch.add(dir.string(), "", "."));
REQUIRE(watch.add(dir.string(), name, name));
CHECK_EQ(watch.count(), 2);
std::this_thread::sleep_for(std::chrono::milliseconds(1200));
eve::filesystem::FileWatch::Event event;
while (watch.poll(event)) {
}
{
std::ofstream out(dir / name, std::ios::binary | std::ios::trunc);
REQUIRE(out.good());
out.write("v2-changed", 10);
out.flush();
}
std::this_thread::sleep_for(std::chrono::milliseconds(1200));
int matchingEvents = 0;
while (watch.poll(event)) {
if (event.realPath.find(name) != std::string::npos) {
++matchingEvents;
CHECK_EQ(event.path, std::string(name));
}
}
CHECK_EQ(matchingEvents, 1);
watch.clear();
std::filesystem::remove_all(dir);
}
TEST_CASE("filesystem.watch.unwatchAll") {
useIdentity("ev_ut_fs_watch_all");
auto* f = fs();
f->unwatchAll();
f->write("a.txt", "a", 1);
f->write("b.txt", "b", 1);
CHECK(f->watch("a.txt"));
CHECK(f->watch("b.txt"));
CHECK_GE(f->getWatchCount(), 2);
f->unwatchAll();
CHECK_EQ(f->getWatchCount(), 0);
f->remove("a.txt");
f->remove("b.txt");
}
TEST_CASE("filesystem.watch.dotResolvesToCwd") {
useIdentity("ev_ut_fs_watch_dot");
auto *f = fs();
f->unwatchAll();
std::string cwd = f->getWorkingDirectory();
REQUIRE(!cwd.empty());
(void)f->setSource(cwd);
REQUIRE(f->watch("."));
CHECK_GE(f->getWatchCount(), 1);
const char *name = "ut_watch_dot.txt";
{
std::ofstream out(cwd + "/" + name, std::ios::binary | std::ios::trunc);
REQUIRE(out.good());
out.write("v1", 2);
out.flush();
}
std::this_thread::sleep_for(std::chrono::milliseconds(1200));
while (!f->pollWatch().empty()) {
}
{
std::ofstream out(cwd + "/" + name, std::ios::binary | std::ios::trunc);
REQUIRE(out.good());
out.write("v2-changed", 10);
out.flush();
}
bool saw = false;
for (int i = 0; i < 60 && !saw; ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
for (;;) {
std::string kind = f->pollWatch();
if (kind.empty()) break;
if (kind == "modified" || kind == "added") {
std::string p = f->getLastWatchPath();
if (p == name || p == std::string("./") + name ||
p.find(name) != std::string::npos)
saw = true;
}
}
}
CHECK(saw);
f->unwatch(".");
std::remove((cwd + "/" + name).c_str());
}