CultLib/tests/GameCult.Caching.Tests/CultDocumentRegistryTests.cs

171 lines
8.4 KiB
C#
Raw Normal View History

2026-07-13 17:16:41 +01:00
using System;
using System.Linq;
using System.Threading.Tasks;
2026-07-13 17:16:41 +01:00
using NUnit.Framework;
using static GameCult.Caching.Tests.EmittedDocumentTypes;
2026-07-13 17:16:41 +01:00
namespace GameCult.Caching.Tests;
[TestFixture]
public sealed class CultDocumentRegistryTests
{
[Test]
public void ForTypesContainsOnlyExplicitDocuments()
{
var requested = Emit("explicit", "tests.registry.explicit", "v1");
var unrelated = Emit("unrelated", "tests.registry.unrelated", "v1");
var registry = CultDocumentRegistry.ForTypes(new[] { requested, requested });
Assert.That(registry.AllDescriptors.Select(value => value.DocumentType), Is.EqualTo(new[] { requested }));
Assert.That(registry.AllDescriptors.Any(value => value.DocumentType == unrelated), Is.False);
}
2026-07-13 17:16:41 +01:00
[Test]
public void IdenticalTypeRegistrationIsIdempotent()
{
var registry = new CultDocumentRegistry();
var type = Emit("idempotent", "tests.registry.idempotent", "v1");
2026-07-13 17:16:41 +01:00
var first = registry.GetRequired(type);
var second = registry.GetRequired(type);
Assert.That(second, Is.SameAs(first));
}
[Test]
public void DifferentVersionsSharingSchemaNameRegisterWithoutReflectionOrderSelection()
{
var registry = new CultDocumentRegistry();
var firstType = Emit("version_one", "tests.registry.versioned", "v1");
var secondType = Emit("version_two", "tests.registry.versioned", "v2");
2026-07-13 17:16:41 +01:00
var first = registry.GetRequired(firstType);
var second = registry.GetRequired(secondType);
Assert.Multiple(() =>
{
Assert.That(registry.GetRequiredBySchemaId(first.SchemaId), Is.SameAs(first));
Assert.That(registry.GetRequiredBySchemaId(second.SchemaId), Is.SameAs(second));
});
var persisted = new CultSchemaCatalogEntry
{
SchemaId = "tests.registry.versioned.unknown",
SchemaName = "tests.registry.versioned",
SchemaVersion = "legacy"
};
Assert.That(
() => registry.ResolvePersistedSchema(persisted.SchemaId, new[] { persisted }),
Throws.TypeOf<InvalidOperationException>()
.With.Message.Contains("ambiguous across local versions")
.And.Message.Contains("v1")
.And.Message.Contains("v2"));
}
[Test]
public void ConcurrentRefreshAndReadsObserveCompleteRegistrySnapshots()
{
var registry = new CultDocumentRegistry();
var type = Emit("refresh_visible", "tests.registry.refresh_visible", "v1");
var descriptor = registry.GetRequired(type);
Parallel.For(0, 20, _ =>
{
registry.Refresh();
Assert.That(registry.GetRequired(type).SchemaId, Is.EqualTo(descriptor.SchemaId));
Assert.That(registry.GetRequiredBySchemaId(descriptor.SchemaId).SchemaId, Is.EqualTo(descriptor.SchemaId));
});
2026-07-13 17:16:41 +01:00
}
// A second type for one schema is refused, typed, naming both and the claimed id, and the registry keeps what it had.
private static void AssertSecondClaimRefused(Type first, Type second)
2026-07-13 17:16:41 +01:00
{
var schemaId = CultDocumentRegistry.ForTypes(new[] { second }).GetRequired(second).SchemaId;
var refusal = Assert.Throws<CultSchemaConflictException>(() => CultDocumentRegistry.ForTypes(new[] { first, second }))!;
Assert.Multiple(() =>
2026-07-13 17:16:41 +01:00
{
Assert.That(refusal.SchemaId, Is.EqualTo(schemaId));
Assert.That(refusal.Message, Does.Contain(first.FullName).And.Contain(second.FullName));
});
var registry = CultDocumentRegistry.ForTypes(new[] { first });
Assert.That(() => registry.GetRequired(second), Throws.TypeOf<CultSchemaConflictException>());
Assert.That(registry.AllDescriptors.Select(descriptor => descriptor.DocumentType), Is.EqualTo(new[] { first }));
}
C#: a type listing an id registers beside the type that owns it; the owner wins Batch 9 refused any overlap between two types' ids, which forbade the migration CompatibleSchemaIds exists for: v2 declares v1's id while v1's class stays registered (Soul F8). The rule is now owner beats lister. A type owns its own schema id; any number of types may list an id as compatible beside its owner, registering in either order. A record under an id resolves to its owner, else to the one type listing it. An id no type owns and several list names no single type, so a record under it is refused, typed (CultSchemaConflictException naming the id and the listers), where it resolves: refusing at registration would depend on whether the owner had registered yet. A second owner of one id is still refused at registration, typed. A schema id hashes the schema's name and version, so the only second owner is a type of the same name and version, which the name-and-version check already meets; the separate own-id lookup could never fire and is gone. Tests: an owner and a lister register in both orders, the owner's id resolves to the owner, the lister's own id to the lister, and without the owner to the lister; two listers register in both orders, a record under their id is refused typed with no owner and resolves to the owner with one, registered before or after them; a cache with v1 beside v2 reads each version's records as its own type, in both registration orders. The emitted-fixture note says why a fixture is emitted now that v1/v2 pairs no longer need hiding from the shared registry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:26:06 +02:00
private static CultSchemaCatalogEntry[] CatalogOf(params Type[] types) =>
types.Select(type => CultDocumentRegistry.ForTypes(new[] { type }).GetRequired(type).ToCatalogEntry()).ToArray();
// Owner beats lister: a type that owns an id and one that lists it register together in either order (the v1 class kept
// beside a v2 class declaring v1's id). A record under the id resolves to its owner; one under the lister's own id to the
// lister; without the owner registered, the id resolves to the lister.
[TestCase(false)]
[TestCase(true)]
C#: a type listing an id registers beside the type that owns it; the owner wins Batch 9 refused any overlap between two types' ids, which forbade the migration CompatibleSchemaIds exists for: v2 declares v1's id while v1's class stays registered (Soul F8). The rule is now owner beats lister. A type owns its own schema id; any number of types may list an id as compatible beside its owner, registering in either order. A record under an id resolves to its owner, else to the one type listing it. An id no type owns and several list names no single type, so a record under it is refused, typed (CultSchemaConflictException naming the id and the listers), where it resolves: refusing at registration would depend on whether the owner had registered yet. A second owner of one id is still refused at registration, typed. A schema id hashes the schema's name and version, so the only second owner is a type of the same name and version, which the name-and-version check already meets; the separate own-id lookup could never fire and is gone. Tests: an owner and a lister register in both orders, the owner's id resolves to the owner, the lister's own id to the lister, and without the owner to the lister; two listers register in both orders, a record under their id is refused typed with no owner and resolves to the owner with one, registered before or after them; a cache with v1 beside v2 reads each version's records as its own type, in both registration orders. The emitted-fixture note says why a fixture is emitted now that v1/v2 pairs no longer need hiding from the shared registry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:26:06 +02:00
public void AnOwnerAndATypeListingItsIdRegisterTogetherAndTheOwnerWins(bool reversed)
{
C#: a type listing an id registers beside the type that owns it; the owner wins Batch 9 refused any overlap between two types' ids, which forbade the migration CompatibleSchemaIds exists for: v2 declares v1's id while v1's class stays registered (Soul F8). The rule is now owner beats lister. A type owns its own schema id; any number of types may list an id as compatible beside its owner, registering in either order. A record under an id resolves to its owner, else to the one type listing it. An id no type owns and several list names no single type, so a record under it is refused, typed (CultSchemaConflictException naming the id and the listers), where it resolves: refusing at registration would depend on whether the owner had registered yet. A second owner of one id is still refused at registration, typed. A schema id hashes the schema's name and version, so the only second owner is a type of the same name and version, which the name-and-version check already meets; the separate own-id lookup could never fire and is gone. Tests: an owner and a lister register in both orders, the owner's id resolves to the owner, the lister's own id to the lister, and without the owner to the lister; two listers register in both orders, a record under their id is refused typed with no owner and resolves to the owner with one, registered before or after them; a cache with v1 beside v2 reads each version's records as its own type, in both registration orders. The emitted-fixture note says why a fixture is emitted now that v1/v2 pairs no longer need hiding from the shared registry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:26:06 +02:00
var owner = Emit("owns", "tests.registry.owns", "v1");
var ownerId = CultDocumentRegistry.ForTypes(new[] { owner }).GetRequired(owner).SchemaId;
var lister = Emit("lists", "tests.registry.owns", "v2", new[] { new Field("Extra", typeof(string), 0) }, new[] { ownerId });
var listerId = CultDocumentRegistry.ForTypes(new[] { lister }).GetRequired(lister).SchemaId;
var catalog = CatalogOf(owner, lister);
C#: a type listing an id registers beside the type that owns it; the owner wins Batch 9 refused any overlap between two types' ids, which forbade the migration CompatibleSchemaIds exists for: v2 declares v1's id while v1's class stays registered (Soul F8). The rule is now owner beats lister. A type owns its own schema id; any number of types may list an id as compatible beside its owner, registering in either order. A record under an id resolves to its owner, else to the one type listing it. An id no type owns and several list names no single type, so a record under it is refused, typed (CultSchemaConflictException naming the id and the listers), where it resolves: refusing at registration would depend on whether the owner had registered yet. A second owner of one id is still refused at registration, typed. A schema id hashes the schema's name and version, so the only second owner is a type of the same name and version, which the name-and-version check already meets; the separate own-id lookup could never fire and is gone. Tests: an owner and a lister register in both orders, the owner's id resolves to the owner, the lister's own id to the lister, and without the owner to the lister; two listers register in both orders, a record under their id is refused typed with no owner and resolves to the owner with one, registered before or after them; a cache with v1 beside v2 reads each version's records as its own type, in both registration orders. The emitted-fixture note says why a fixture is emitted now that v1/v2 pairs no longer need hiding from the shared registry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:26:06 +02:00
var registry = CultDocumentRegistry.ForTypes(reversed ? new[] { lister, owner } : new[] { owner, lister });
Assert.Multiple(() =>
{
Assert.That(registry.AllDescriptors.Select(descriptor => descriptor.DocumentType), Is.EquivalentTo(new[] { owner, lister }));
Assert.That(registry.ResolvePersistedSchema(ownerId, catalog).DocumentType, Is.EqualTo(owner));
Assert.That(registry.ResolvePersistedSchema(listerId, catalog).DocumentType, Is.EqualTo(lister));
Assert.That(CultDocumentRegistry.ForTypes(new[] { lister }).ResolvePersistedSchema(ownerId, catalog).DocumentType, Is.EqualTo(lister));
});
}
C#: a type listing an id registers beside the type that owns it; the owner wins Batch 9 refused any overlap between two types' ids, which forbade the migration CompatibleSchemaIds exists for: v2 declares v1's id while v1's class stays registered (Soul F8). The rule is now owner beats lister. A type owns its own schema id; any number of types may list an id as compatible beside its owner, registering in either order. A record under an id resolves to its owner, else to the one type listing it. An id no type owns and several list names no single type, so a record under it is refused, typed (CultSchemaConflictException naming the id and the listers), where it resolves: refusing at registration would depend on whether the owner had registered yet. A second owner of one id is still refused at registration, typed. A schema id hashes the schema's name and version, so the only second owner is a type of the same name and version, which the name-and-version check already meets; the separate own-id lookup could never fire and is gone. Tests: an owner and a lister register in both orders, the owner's id resolves to the owner, the lister's own id to the lister, and without the owner to the lister; two listers register in both orders, a record under their id is refused typed with no owner and resolves to the owner with one, registered before or after them; a cache with v1 beside v2 reads each version's records as its own type, in both registration orders. The emitted-fixture note says why a fixture is emitted now that v1/v2 pairs no longer need hiding from the shared registry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:26:06 +02:00
// Two types listing one id register together in either order. With an owner registered, a record under the id is the
// owner's; with none, the id names no single type, and a record under it is refused, typed, naming the listers.
[TestCase(false)]
[TestCase(true)]
C#: a type listing an id registers beside the type that owns it; the owner wins Batch 9 refused any overlap between two types' ids, which forbade the migration CompatibleSchemaIds exists for: v2 declares v1's id while v1's class stays registered (Soul F8). The rule is now owner beats lister. A type owns its own schema id; any number of types may list an id as compatible beside its owner, registering in either order. A record under an id resolves to its owner, else to the one type listing it. An id no type owns and several list names no single type, so a record under it is refused, typed (CultSchemaConflictException naming the id and the listers), where it resolves: refusing at registration would depend on whether the owner had registered yet. A second owner of one id is still refused at registration, typed. A schema id hashes the schema's name and version, so the only second owner is a type of the same name and version, which the name-and-version check already meets; the separate own-id lookup could never fire and is gone. Tests: an owner and a lister register in both orders, the owner's id resolves to the owner, the lister's own id to the lister, and without the owner to the lister; two listers register in both orders, a record under their id is refused typed with no owner and resolves to the owner with one, registered before or after them; a cache with v1 beside v2 reads each version's records as its own type, in both registration orders. The emitted-fixture note says why a fixture is emitted now that v1/v2 pairs no longer need hiding from the shared registry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:26:06 +02:00
public void TwoTypesListingOneIdRegisterAndARecordUnderItNeedsAnOwner(bool reversed)
{
C#: a type listing an id registers beside the type that owns it; the owner wins Batch 9 refused any overlap between two types' ids, which forbade the migration CompatibleSchemaIds exists for: v2 declares v1's id while v1's class stays registered (Soul F8). The rule is now owner beats lister. A type owns its own schema id; any number of types may list an id as compatible beside its owner, registering in either order. A record under an id resolves to its owner, else to the one type listing it. An id no type owns and several list names no single type, so a record under it is refused, typed (CultSchemaConflictException naming the id and the listers), where it resolves: refusing at registration would depend on whether the owner had registered yet. A second owner of one id is still refused at registration, typed. A schema id hashes the schema's name and version, so the only second owner is a type of the same name and version, which the name-and-version check already meets; the separate own-id lookup could never fire and is gone. Tests: an owner and a lister register in both orders, the owner's id resolves to the owner, the lister's own id to the lister, and without the owner to the lister; two listers register in both orders, a record under their id is refused typed with no owner and resolves to the owner with one, registered before or after them; a cache with v1 beside v2 reads each version's records as its own type, in both registration orders. The emitted-fixture note says why a fixture is emitted now that v1/v2 pairs no longer need hiding from the shared registry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:26:06 +02:00
var owner = Emit("shared_owner", "tests.registry.shared", "v1");
var sharedId = CultDocumentRegistry.ForTypes(new[] { owner }).GetRequired(owner).SchemaId;
var a = Emit("declares_a", "tests.registry.declares_a", "v1", compatibleSchemaIds: new[] { sharedId });
var b = Emit("declares_b", "tests.registry.declares_b", "v1", compatibleSchemaIds: new[] { sharedId });
var listers = reversed ? new[] { b, a } : new[] { a, b };
var catalog = CatalogOf(owner);
var ambiguous = CultDocumentRegistry.ForTypes(listers);
var refusal = Assert.Throws<CultSchemaConflictException>(() => ambiguous.ResolvePersistedSchema(sharedId, catalog))!;
Assert.Multiple(() =>
{
Assert.That(refusal.SchemaId, Is.EqualTo(sharedId));
Assert.That(refusal.SchemaNames, Is.EquivalentTo(new[] { "tests.registry.declares_a", "tests.registry.declares_b" }));
Assert.That(refusal.Message, Does.Contain(a.FullName).And.Contain(b.FullName));
});
C#: a type listing an id registers beside the type that owns it; the owner wins Batch 9 refused any overlap between two types' ids, which forbade the migration CompatibleSchemaIds exists for: v2 declares v1's id while v1's class stays registered (Soul F8). The rule is now owner beats lister. A type owns its own schema id; any number of types may list an id as compatible beside its owner, registering in either order. A record under an id resolves to its owner, else to the one type listing it. An id no type owns and several list names no single type, so a record under it is refused, typed (CultSchemaConflictException naming the id and the listers), where it resolves: refusing at registration would depend on whether the owner had registered yet. A second owner of one id is still refused at registration, typed. A schema id hashes the schema's name and version, so the only second owner is a type of the same name and version, which the name-and-version check already meets; the separate own-id lookup could never fire and is gone. Tests: an owner and a lister register in both orders, the owner's id resolves to the owner, the lister's own id to the lister, and without the owner to the lister; two listers register in both orders, a record under their id is refused typed with no owner and resolves to the owner with one, registered before or after them; a cache with v1 beside v2 reads each version's records as its own type, in both registration orders. The emitted-fixture note says why a fixture is emitted now that v1/v2 pairs no longer need hiding from the shared registry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:26:06 +02:00
Assert.That(CultDocumentRegistry.ForTypes(listers.Append(owner)).ResolvePersistedSchema(sharedId, catalog).DocumentType, Is.EqualTo(owner));
Assert.That(CultDocumentRegistry.ForTypes(listers.Prepend(owner)).ResolvePersistedSchema(sharedId, catalog).DocumentType, Is.EqualTo(owner));
}
// One type per schema, in every runtime: a second type of the same schema name and version is refused whichever registers
// first, whether it is identical to the first, declares different compatible ids, or has different members.
[TestCase("identical", false)]
[TestCase("identical", true)]
[TestCase("declaration", false)]
[TestCase("declaration", true)]
[TestCase("members", false)]
[TestCase("members", true)]
public void ASecondTypeForOneSchemaIsRefused(string difference, bool reversed)
{
var schemaName = $"tests.registry.claimed_twice_{difference}";
var first = Emit($"first_{difference}", schemaName, "v1");
var second = difference switch
{
"identical" => Emit($"second_{difference}", schemaName, "v1"),
"declaration" => Emit($"second_{difference}", schemaName, "v1", compatibleSchemaIds: new[] { "tests.registry.older" }),
_ => Emit($"second_{difference}", schemaName, "v1", new[] { new Field("Value", typeof(string), 0) }),
};
2026-07-13 17:16:41 +01:00
AssertSecondClaimRefused(reversed ? second : first, reversed ? first : second);
2026-07-13 17:16:41 +01:00
}
}