Readability manifest: legacy-catalog c0 rows and directory-at-path, walked by all four runtimes #114
6 changed files with 114 additions and 25 deletions
|
|
@ -1567,15 +1567,19 @@ class CultCacheTests(unittest.TestCase):
|
|||
# Every vector in the folder has a manifest row.
|
||||
self.assertEqual(
|
||||
sorted(path.name for path in root.glob("*.bin")),
|
||||
sorted(vector for vector, *_ in rows if not vector.startswith("..")),
|
||||
sorted(vector for vector, *_ in rows if not vector.startswith(("..", "@"))),
|
||||
)
|
||||
envelopes = SingleFileMessagePackBackingStore(self._C2A_VECTORS / "v3-base.msgpack").pull_all()
|
||||
for vector, _, _, _, python in rows:
|
||||
content = (root / vector).read_bytes()
|
||||
# "@directory": the walker makes an empty directory at the store path; it is refused and left untouched.
|
||||
content = None if vector == "@directory" else (root / vector).read_bytes()
|
||||
for operation in ("pull_all", "push_all", "push", "delete"):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store_path = Path(tmp) / "store.msgpack"
|
||||
store_path.write_bytes(content)
|
||||
if content is None:
|
||||
store_path.mkdir()
|
||||
else:
|
||||
store_path.write_bytes(content)
|
||||
store = SingleFileMessagePackBackingStore(store_path)
|
||||
run = {
|
||||
"pull_all": store.pull_all,
|
||||
|
|
@ -1598,6 +1602,11 @@ class CultCacheTests(unittest.TestCase):
|
|||
self.assertIn(envelopes[0].key, keys, f"{vector} {operation}")
|
||||
else:
|
||||
self.assertNotIn(envelopes[0].key, keys, f"{vector} {operation}")
|
||||
elif content is None:
|
||||
# A path that cannot be reached as a file stays an OSError (_read_store): not a store that failed to decode.
|
||||
with self.assertRaises(OSError, msg=f"{vector} {operation}"):
|
||||
run()
|
||||
self.assertTrue(store_path.is_dir() and not any(store_path.iterdir()), f"{vector} {operation} touched the directory")
|
||||
else:
|
||||
with self.assertRaises(StoreUnreadableError, msg=f"{vector} {operation}") as refused:
|
||||
run()
|
||||
|
|
|
|||
|
|
@ -6140,16 +6140,22 @@ mod tests {
|
|||
for line in manifest.lines().filter(|line| !line.is_empty() && !line.starts_with('#')) {
|
||||
let cells: Vec<&str> = line.split_whitespace().collect();
|
||||
let (vector, reads) = (cells[0], cells[2] == "reads");
|
||||
if !vector.starts_with("..") {
|
||||
if !vector.starts_with("..") && !vector.starts_with('@') {
|
||||
listed.push(vector.to_string());
|
||||
}
|
||||
let bytes = std::fs::read(root.join(vector))?;
|
||||
// `@directory`: the walker puts an empty directory at the store path; it is refused and left untouched.
|
||||
let directory = vector == "@directory";
|
||||
let bytes = if directory { Vec::new() } else { std::fs::read(root.join(vector))? };
|
||||
// A v3 store keeps its marker through a rewrite; every other file that reads is written v1.
|
||||
let header = if vector.contains("v3") { "cultcache.store.v3" } else { STORE_FORMAT_V1 };
|
||||
for operation in ["open", "soft_push_all", "push_all", "push"] {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let path = temp.path().join("store.msgpack");
|
||||
std::fs::write(&path, &bytes)?;
|
||||
if directory {
|
||||
std::fs::create_dir(&path)?;
|
||||
} else {
|
||||
std::fs::write(&path, &bytes)?;
|
||||
}
|
||||
let mut store = SingleFileMessagePackBackingStore::new(&path);
|
||||
let outcome = match operation {
|
||||
"open" => store.pull_all().map(|_| ()),
|
||||
|
|
@ -6159,6 +6165,10 @@ mod tests {
|
|||
};
|
||||
let what = format!("{vector} {operation}");
|
||||
assert_eq!(outcome.is_ok(), reads, "{what}: {outcome:?}");
|
||||
if directory {
|
||||
assert!(path.is_dir() && std::fs::read_dir(&path)?.next().is_none(), "{what} touched the directory");
|
||||
continue;
|
||||
}
|
||||
if operation == "soft_push_all" && reads {
|
||||
// A soft flush leaves a store it reads byte for byte.
|
||||
assert_eq!(std::fs::read(&path)?, bytes, "{what} rewrote a store it reads");
|
||||
|
|
|
|||
|
|
@ -2582,17 +2582,24 @@ test("SingleFileMessagePackBackingStore replaces a file exactly when it reads",
|
|||
.filter((line) => line.length > 0 && !line.startsWith("#"))
|
||||
.map((line) => line.split(/\s+/u));
|
||||
// Every vector in the folder has a manifest row.
|
||||
const listed = rows.map(([vector]) => vector!).filter((vector) => !vector.startsWith("..")).sort();
|
||||
const listed = rows.map(([vector]) => vector!).filter((vector) => !vector.startsWith("..") && !vector.startsWith("@")).sort();
|
||||
assert.deepEqual((await readdir(root)).filter((name) => name.endsWith(".bin")).sort(), listed);
|
||||
const dir = await mkdtemp(join(tmpdir(), "cultcache-readability-"));
|
||||
const seed = await copyVector(dir, join(c2aVectors, "v3-base.msgpack"), "seed.msgpack");
|
||||
const envelopes = await new SingleFileMessagePackBackingStore(seed).pullAll();
|
||||
for (const [vector, , , typescript] of rows) {
|
||||
const reads = typescript === "reads";
|
||||
const bytes = await readFile(join(root, vector));
|
||||
// "@directory": the walker makes an empty directory at the store path; the runtime refuses to open or replace it and leaves it be.
|
||||
const isDirectory = vector === "@directory";
|
||||
const bytes = isDirectory ? Buffer.alloc(0) : await readFile(join(root, vector));
|
||||
for (const operation of ["pullAll", "softPushAll", "pushAll", "push", "delete"] as const) {
|
||||
const file = join(dir, `${vector.replace(/[^a-z0-9.-]/giu, "_")}.${operation}.msgpack`);
|
||||
await writeFile(file, bytes);
|
||||
if (isDirectory) {
|
||||
assert.equal(typescript, "refuses", "a directory is never a store");
|
||||
await mkdir(file);
|
||||
} else {
|
||||
await writeFile(file, bytes);
|
||||
}
|
||||
const store = new SingleFileMessagePackBackingStore(file);
|
||||
const run = () =>
|
||||
operation === "pullAll"
|
||||
|
|
@ -2627,6 +2634,10 @@ test("SingleFileMessagePackBackingStore replaces a file exactly when it reads",
|
|||
} else {
|
||||
assert.ok(!keys.includes(envelopes[0]!.key), `${vector} ${operation}`);
|
||||
}
|
||||
} else if (isDirectory) {
|
||||
await assert.rejects(run, `${vector} ${operation}`);
|
||||
assert.ok((await lstat(file)).isDirectory(), `${vector} ${operation} replaced the directory`);
|
||||
assert.deepEqual(await readdir(file), [], `${vector} ${operation} wrote into the directory`);
|
||||
} else {
|
||||
await assert.rejects(
|
||||
run,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ using System.Linq;
|
|||
using GameCult.Caching.MessagePack;
|
||||
using MessagePack;
|
||||
using NUnit.Framework;
|
||||
using NUnit.Framework.Constraints;
|
||||
using static GameCult.Caching.Tests.ElementIdTests;
|
||||
|
||||
namespace GameCult.Caching.Tests
|
||||
|
|
@ -61,6 +62,39 @@ namespace GameCult.Caching.Tests
|
|||
.Select(line => line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries))
|
||||
.Select(cells => new TestCaseData(cells[0], cells[CSharp] == "reads").SetName($"{cells[0]} {cells[CSharp]}"));
|
||||
|
||||
// The row `@directory` has no bytes: the walker makes an empty directory at the store path, and a runtime must refuse it untouched.
|
||||
private const string DirectoryRow = "@directory";
|
||||
|
||||
private static byte[] Place(string path, string vector)
|
||||
{
|
||||
if (vector != DirectoryRow)
|
||||
{
|
||||
var bytes = File.ReadAllBytes(Path.Combine(VectorRoot(), vector));
|
||||
File.WriteAllBytes(path, bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
Directory.CreateDirectory(path);
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
|
||||
// A directory at the store path is refused as IOException ("not a store file", CultCache.cs): not a store that is unreadable.
|
||||
private static IResolveConstraint Refusal(string vector) =>
|
||||
vector == DirectoryRow ? Throws.TypeOf<IOException>() : Throws.TypeOf<CultStoreUnreadableException>();
|
||||
|
||||
private static void AssertUntouched(string path, string vector, byte[] bytes, string message)
|
||||
{
|
||||
if (vector == DirectoryRow)
|
||||
{
|
||||
Assert.That(Directory.Exists(path), message);
|
||||
Assert.That(Directory.EnumerateFileSystemEntries(path), Is.Empty, message);
|
||||
}
|
||||
else
|
||||
Assert.That(File.ReadAllBytes(path), Is.EqualTo(bytes), message);
|
||||
}
|
||||
|
||||
private static bool HoldsAVariant(byte[] bytes) =>
|
||||
CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes).Records.Any(record => record.Variant != null);
|
||||
|
||||
|
|
@ -90,14 +124,17 @@ namespace GameCult.Caching.Tests
|
|||
public void OpenReadsExactlyTheFilesTheVectorsSayItReads(string vector, bool reads)
|
||||
{
|
||||
var path = Path.Combine(_directory, "open.cc");
|
||||
File.WriteAllBytes(path, File.ReadAllBytes(Path.Combine(VectorRoot(), vector)));
|
||||
var bytes = Place(path, vector);
|
||||
|
||||
void Open() => CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }).Dispose();
|
||||
|
||||
if (reads)
|
||||
Assert.DoesNotThrow(Open);
|
||||
else
|
||||
Assert.That(Open, Throws.TypeOf<CultStoreUnreadableException>().With.Property(nameof(CultStoreUnreadableException.Path)).EqualTo(path));
|
||||
{
|
||||
Assert.That(Open, vector == DirectoryRow ? Refusal(vector) : Throws.TypeOf<CultStoreUnreadableException>().With.Property(nameof(CultStoreUnreadableException.Path)).EqualTo(path));
|
||||
AssertUntouched(path, vector, bytes, "a store this runtime cannot open was changed by the attempt");
|
||||
}
|
||||
}
|
||||
|
||||
[TestCaseSource(nameof(Vectors))]
|
||||
|
|
@ -105,8 +142,7 @@ namespace GameCult.Caching.Tests
|
|||
{
|
||||
var path = Seed("flush.cc");
|
||||
using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry });
|
||||
var bytes = File.ReadAllBytes(Path.Combine(VectorRoot(), vector));
|
||||
File.WriteAllBytes(path, bytes);
|
||||
var bytes = Place(path, vector);
|
||||
var store = cache.BackingStores[0];
|
||||
|
||||
if (reads && HoldsAVariant(bytes))
|
||||
|
|
@ -126,8 +162,8 @@ namespace GameCult.Caching.Tests
|
|||
return;
|
||||
}
|
||||
|
||||
Assert.That(() => store.PushAll(), Throws.TypeOf<CultStoreUnreadableException>());
|
||||
Assert.That(File.ReadAllBytes(path), Is.EqualTo(bytes), "a file this runtime cannot read was rewritten");
|
||||
Assert.That(() => store.PushAll(), Refusal(vector));
|
||||
AssertUntouched(path, vector, bytes, "a file this runtime cannot read was rewritten");
|
||||
}
|
||||
|
||||
[TestCaseSource(nameof(Vectors))]
|
||||
|
|
@ -135,8 +171,7 @@ namespace GameCult.Caching.Tests
|
|||
{
|
||||
var path = Seed("commit.cc");
|
||||
using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry });
|
||||
var bytes = File.ReadAllBytes(Path.Combine(VectorRoot(), vector));
|
||||
File.WriteAllBytes(path, bytes);
|
||||
var bytes = Place(path, vector);
|
||||
|
||||
if (reads && HoldsAVariant(bytes))
|
||||
{
|
||||
|
|
@ -153,8 +188,8 @@ namespace GameCult.Caching.Tests
|
|||
return;
|
||||
}
|
||||
|
||||
Assert.That(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))), Throws.TypeOf<CultStoreUnreadableException>());
|
||||
Assert.That(File.ReadAllBytes(path), Is.EqualTo(bytes), "a file this runtime cannot read was rewritten");
|
||||
Assert.That(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))), Refusal(vector));
|
||||
AssertUntouched(path, vector, bytes, "a file this runtime cannot read was rewritten");
|
||||
}
|
||||
|
||||
private string DirectoryStore(string name)
|
||||
|
|
@ -679,7 +714,7 @@ namespace GameCult.Caching.Tests
|
|||
[Test]
|
||||
public void EveryVectorInTheFolderHasAManifestRow()
|
||||
{
|
||||
var rows = Vectors().Select(row => (string)row.Arguments[0]).Where(name => !name.StartsWith("..", StringComparison.Ordinal)).OrderBy(name => name, StringComparer.Ordinal);
|
||||
var rows = Vectors().Select(row => (string)row.Arguments[0]).Where(name => !name.StartsWith("..", StringComparison.Ordinal) && name != DirectoryRow).OrderBy(name => name, StringComparer.Ordinal);
|
||||
var files = Directory.GetFiles(VectorRoot(), "*.bin").Select(Path.GetFileName).OrderBy(name => name, StringComparer.Ordinal);
|
||||
Assert.That(rows, Is.EqualTo(files));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ namespace GameCult.Mesh.Tests;
|
|||
public sealed class CultMeshSingleFileReadabilityTests
|
||||
{
|
||||
private const int CSharp = 1;
|
||||
// The row `@directory` has no bytes: the walker makes an empty directory at the store path, and the write must refuse it untouched.
|
||||
private const string DirectoryRow = "@directory";
|
||||
private string _root = null!;
|
||||
|
||||
[SetUp]
|
||||
|
|
@ -67,8 +69,15 @@ public sealed class CultMeshSingleFileReadabilityTests
|
|||
foreach (var (name, write) in writes)
|
||||
{
|
||||
var path = Path.Combine(_root, name.Replace(' ', '-') + ".cc");
|
||||
var bytes = File.ReadAllBytes(Path.Combine(VectorRoot(), vector));
|
||||
File.WriteAllBytes(path, bytes);
|
||||
var isDirectory = vector == DirectoryRow;
|
||||
var bytes = Array.Empty<byte>();
|
||||
if (isDirectory)
|
||||
Directory.CreateDirectory(path);
|
||||
else
|
||||
{
|
||||
bytes = File.ReadAllBytes(Path.Combine(VectorRoot(), vector));
|
||||
File.WriteAllBytes(path, bytes);
|
||||
}
|
||||
|
||||
if (reads)
|
||||
{
|
||||
|
|
@ -96,8 +105,18 @@ public sealed class CultMeshSingleFileReadabilityTests
|
|||
continue;
|
||||
}
|
||||
|
||||
Assert.That(() => write(path), Throws.TypeOf<CultStoreUnreadableException>(), name);
|
||||
File.ReadAllBytes(path).Should().Equal(bytes, name + " rewrote a file this runtime cannot read");
|
||||
// A directory at the path is refused as IOException ("not a store file"), not as an unreadable store.
|
||||
if (isDirectory)
|
||||
Assert.That(() => write(path), Throws.TypeOf<IOException>(), name);
|
||||
else
|
||||
Assert.That(() => write(path), Throws.TypeOf<CultStoreUnreadableException>(), name);
|
||||
if (isDirectory)
|
||||
{
|
||||
Directory.Exists(path).Should().BeTrue(name + " replaced a directory");
|
||||
Directory.EnumerateFileSystemEntries(path).Should().BeEmpty(name + " wrote into a directory");
|
||||
}
|
||||
else
|
||||
File.ReadAllBytes(path).Should().Equal(bytes, name + " rewrote a file this runtime cannot read");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -115,7 +134,7 @@ public sealed class CultMeshSingleFileReadabilityTests
|
|||
[Test]
|
||||
public void EveryVectorInTheFolderHasAManifestRow()
|
||||
{
|
||||
var rows = Vectors().Select(row => (string)row.Arguments[0]).Where(name => !name.StartsWith("..", StringComparison.Ordinal)).OrderBy(name => name, StringComparer.Ordinal);
|
||||
var rows = Vectors().Select(row => (string)row.Arguments[0]).Where(name => !name.StartsWith("..", StringComparison.Ordinal) && name != DirectoryRow).OrderBy(name => name, StringComparer.Ordinal);
|
||||
var files = Directory.GetFiles(VectorRoot(), "*.bin").Select(Path.GetFileName).OrderBy(name => name, StringComparer.Ordinal);
|
||||
rows.Should().Equal(files);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
# Whether each runtime's own reader opens the file. A flush and a commit replace a file exactly when it reads; where a runtime has a soft flush (TypeScript, Rust) it leaves a file that reads byte for byte and refuses one that does not.
|
||||
# A store holding two records under one key is unreadable; keys are ordinal strings (no case folding, no normalisation), so distinct spellings are distinct keys.
|
||||
# Columns: file, csharp, rust, typescript, python. `../v3-base.msgpack` is the one valid store.
|
||||
# `@directory` is no file: the walker makes an empty directory at the store path and every runtime must refuse to open it and to write over it (flush, commit, delete), leaving the directory empty.
|
||||
../v3-base.msgpack reads reads reads reads
|
||||
zero-byte.bin refuses refuses refuses refuses
|
||||
empty-array.bin reads reads reads reads
|
||||
|
|
@ -38,3 +39,7 @@ dup-key-different-payload-v3.bin refuses refuses refuses refuses
|
|||
distinct-keys-case-v3.bin reads reads reads reads
|
||||
distinct-keys-nfd-v3.bin reads reads reads reads
|
||||
../../document-variants-c0/current-catalog-bad-canonical.msgpack refuses refuses refuses refuses
|
||||
../../document-variants-c0/legacy-catalog-plain.msgpack refuses refuses refuses refuses
|
||||
../../document-variants-c0/legacy-catalog-extra-slot.msgpack refuses refuses refuses refuses
|
||||
../../document-variants-c0/legacy-catalog-trailing.msgpack refuses refuses refuses refuses
|
||||
@directory refuses refuses refuses refuses
|
||||
|
|
|
|||
Loading…
Reference in a new issue