CultNet selection cut1, commit 0: the cache's public read surface
docs/cultnet-selection-cut.md section 6/7/10: the reflection loop at CultNetDocumentRegistry.cs:325-342 exists only because nothing outside GameCult.Caching can read a declared index value, reference, or numeric member (Members/IndexAccessors are internal, no InternalsVisibleTo to Networking). This adds the one public door instead: CultDocumentMemberView, CultDocumentDescriptor.DeclaredMembers/TryGetIndexValue/TryGetIndexNumber/ ReferencesOf, and CultDocumentRegistry.ResolveTargetLeaves (D9, computed live against the registry, never cached or persisted). Two registration refusals land beside DiscoverMembers' existing DuplicateSlotMessage/HiddenMemberMessage: D10 renames the ArgumentException ToDictionary already threw for two differently-named members sharing one index alias into a rejection that names the schema, both members and the alias; D11 refuses a reference member whose CLR shape the cache cannot walk (many:false needs CultRecordRef<T>, many:true needs IEnumerable<CultRecordRef<T>> or IDictionary<CultRecordRef<T>, V>) instead of silently answering it as unreadable later. GameCult.Networking is untouched; the evaluator that will read this surface does not exist yet (commit 1). Verification: dotnet test tests/GameCult.Caching.Tests - 188 passed (176 existing + 12 new), 0 failed. New tests: TryGetIndexValue/TryGetIndexNumber reading an inherited alias; ReferencesOf over a single/many-list/many-dictionary reference, including two dictionary entries with different payload bytes (S19); ResolveTargetLeaves against an abstract target with no [CultDocument] of its own (D9), including a leaf registered after an earlier descriptor was built; IsNumeric agreement for a nullable numeric and a decimal member (S22); the two new registration refusals, dynamically emitted so registry discovery by attribute scan cannot see the rejected shapes. scripts/mutate-cultnet-selection-caching.ps1: a dedicated dotnet/NUnit mutation runner (the shared tools/eureka-mutations.ps1 harness only speaks cargo; scripts/mutate-cultmesh.mjs is the QUIC campaign's file and this campaign does not edit it, per the map's section 10). Byte-exact restore verified by SHA-256, a sidecar for crash recovery, M0 no-op control, one anchor match required per entry. Six entries (D8/S22, D10, D11 - revert and loosening each) all KILLED; final restore verified green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
b3d9cf7821
commit
e420410fe9
3 changed files with 782 additions and 13 deletions
198
scripts/mutate-cultnet-selection-caching.ps1
Normal file
198
scripts/mutate-cultnet-selection-caching.ps1
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
# Mutation check for CultNet typed selection, Cut 1, commit 0: the cache's
|
||||
# public read surface (docs/cultnet-selection-cut.md, section 6, D8/D10/D11).
|
||||
#
|
||||
# This is a dedicated runner for src/GameCult.Caching, not the shared
|
||||
# tools/eureka-mutations.ps1 harness: that harness's Invoke-Cargo parses
|
||||
# `cargo test` output and appends `-- --exact <test>`, and is not usable
|
||||
# against `dotnet test`/NUnit. It is also not scripts/mutate-cultmesh.mjs -
|
||||
# that file is the QUIC campaign's, live and dirty in this tree, and this
|
||||
# campaign does not edit it (docs/cultnet-selection-cut.md, section 10: "this
|
||||
# campaign adds its entries in its own file ... and does not edit the QUIC
|
||||
# campaign's targets - Self decides the exact split at landing").
|
||||
#
|
||||
# Same discipline as both: every entry mutates CultCache.cs, is applied as a
|
||||
# byte-exact splice against one anchor that must match exactly once, restored
|
||||
# from the original bytes (never from git) with a SHA-256 check, and a
|
||||
# sidecar (<target>.mutation-original) protects a hard kill mid-mutation. M0
|
||||
# is a no-op control: the target round-trips through the read/write path
|
||||
# unchanged and the unmutated suite is green, before any entry runs.
|
||||
#
|
||||
# powershell -File scripts/mutate-cultnet-selection-caching.ps1
|
||||
#
|
||||
# Exit code: 0 when every entry's revert and loosening (where one is named)
|
||||
# killed the test named in Killer, non-zero otherwise. Output for a run that
|
||||
# did not go green names which entry and which mutant survived.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$repo = Resolve-Path (Join-Path $PSScriptRoot '..')
|
||||
$target = Join-Path $repo 'src\GameCult.Caching\CultCache.cs'
|
||||
$sidecar = "$target.mutation-original"
|
||||
$testProject = Join-Path $repo 'tests\GameCult.Caching.Tests\GameCult.Caching.Tests.csproj'
|
||||
$utf8 = [System.Text.UTF8Encoding]::new($false)
|
||||
|
||||
function Read-Bytes([string]$path) { [System.IO.File]::ReadAllBytes($path) }
|
||||
function Get-BytesHash([byte[]]$bytes) {
|
||||
([System.Security.Cryptography.SHA256]::Create().ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join ''
|
||||
}
|
||||
function Write-Text([string]$path, [string]$text) {
|
||||
[System.IO.File]::WriteAllBytes($path, [byte[]]($utf8.GetPreamble() + $utf8.GetBytes($text)))
|
||||
}
|
||||
|
||||
function Run-Tests([string]$filter) {
|
||||
$args = @('test', $testProject, '-c', 'Debug', '--filter', $filter, '--nologo')
|
||||
$output = & dotnet @args 2>&1 | Out-String
|
||||
Write-Host $output
|
||||
$passedMatch = [regex]::Match($output, 'Passed:\s+(\d+)')
|
||||
$failedMatch = [regex]::Match($output, 'Failed:\s+(\d+)')
|
||||
$compiled = -not ($output -match 'error CS\d+')
|
||||
$passedLine = $output -match 'Passed!'
|
||||
$failedLine = $output -match 'Failed!'
|
||||
[pscustomobject]@{
|
||||
Compiled = $compiled
|
||||
Green = $compiled -and $passedLine -and -not $failedLine -and $passedMatch.Success -and [int]$passedMatch.Groups[1].Value -ge 1
|
||||
Passed = if ($passedMatch.Success) { [int]$passedMatch.Groups[1].Value } else { 0 }
|
||||
Failed = if ($failedMatch.Success) { [int]$failedMatch.Groups[1].Value } else { 0 }
|
||||
}
|
||||
}
|
||||
|
||||
# --- Repair a run that died mid-mutation ------------------------------------
|
||||
|
||||
if (Test-Path -LiteralPath $sidecar) {
|
||||
$original = Read-Bytes $sidecar
|
||||
[System.IO.File]::WriteAllBytes($target, $original)
|
||||
Remove-Item -LiteralPath $sidecar -Force
|
||||
Write-Host "Repaired: a previous run left $sidecar; $target restored from it and the sidecar removed."
|
||||
}
|
||||
|
||||
# --- M0: no-op control -------------------------------------------------------
|
||||
|
||||
Write-Host '--- M0: no-op control'
|
||||
$originalBytes = Read-Bytes $target
|
||||
$originalHash = Get-BytesHash $originalBytes
|
||||
$originalText = $utf8.GetString($originalBytes)
|
||||
$reencoded = [byte[]]($utf8.GetPreamble() + $utf8.GetBytes($originalText))
|
||||
if ((Get-BytesHash $reencoded) -ne $originalHash) {
|
||||
throw "harness broken: $target does not round-trip through the UTF-8 read/write path byte for byte. Nothing was written and no entry ran."
|
||||
}
|
||||
[System.IO.File]::WriteAllBytes($sidecar, $originalBytes)
|
||||
try {
|
||||
Write-Text $target $originalText
|
||||
if ((Get-BytesHash (Read-Bytes $target)) -ne $originalHash) {
|
||||
throw "harness broken: writing $target back through the harness path changed its bytes."
|
||||
}
|
||||
}
|
||||
finally {
|
||||
[System.IO.File]::WriteAllBytes($target, $originalBytes)
|
||||
if ((Get-BytesHash (Read-Bytes $target)) -ne $originalHash) { throw "RESTORE FAILED after M0; original SHA-256 $originalHash is kept in $sidecar." }
|
||||
Remove-Item -LiteralPath $sidecar -Force
|
||||
}
|
||||
$control = Run-Tests 'FullyQualifiedName~CultDocumentSelectionSurfaceTests'
|
||||
if (-not $control.Green) {
|
||||
throw "harness broken: the caching suite is not green unmutated (compiled=$($control.Compiled) passed=$($control.Passed) failed=$($control.Failed)). No entry ran."
|
||||
}
|
||||
Write-Host "M0: green, $($control.Passed) passed"
|
||||
|
||||
# --- Entries -----------------------------------------------------------------
|
||||
|
||||
$entries = @(
|
||||
[pscustomobject]@{
|
||||
Id = 'CACHE-D8-Revert'
|
||||
Rule = 'D8/S22: IsNumeric is derived from the closed CLR numeric set; a decimal-typed member must be numeric.'
|
||||
Mutant = 'revert'
|
||||
Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.IsNumericAgreesForANullableNumericMemberAndADecimalMember'
|
||||
Old = "typeof(int), typeof(uint), typeof(long), typeof(ulong),`n typeof(float), typeof(double), typeof(decimal)"
|
||||
New = "typeof(int), typeof(uint), typeof(long), typeof(ulong),`n typeof(float), typeof(double)"
|
||||
},
|
||||
[pscustomobject]@{
|
||||
Id = 'CACHE-D8-Loosening'
|
||||
Rule = 'D8/S22: a string-typed member must not be treated as numeric.'
|
||||
Mutant = 'loosening'
|
||||
Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.IsNumericIsFalseForAStringAlias'
|
||||
Old = "var underlying = Nullable.GetUnderlyingType(type) ?? type;`n return NumericClrTypes.Contains(underlying);"
|
||||
New = "return true;"
|
||||
},
|
||||
[pscustomobject]@{
|
||||
Id = 'CACHE-D10-Revert'
|
||||
Rule = 'D10: two differently-named members sharing one index alias are refused by name at registration.'
|
||||
Mutant = 'revert'
|
||||
Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.DuplicateIndexAliasOnTwoDifferentlyNamedMembersIsRefusedByName'
|
||||
Old = " // D10: two differently-named members that independently declare the same index alias. A`n // hiding member sharing one slot is already caught above; this is two distinct members whose`n // alias map entry (BuildDescriptor's indexAccessors) would otherwise collide silently.`n foreach (var group in keyed`n .Select(entry => (Entry: entry, Alias: ResolveIndexAlias(entry.Member)))`n .Where(pair => pair.Alias != null)`n .GroupBy(pair => pair.Alias, StringComparer.Ordinal)`n .Where(group => group.Select(pair => pair.Entry.Member.Name).Distinct().Count() > 1)`n .OrderBy(group => group.Key, StringComparer.Ordinal))`n {`n var pair = group.Take(2).ToArray();`n rejections.Add(DuplicateIndexAliasMessage(type.Name, Qualified(pair[0].Entry.Member), Qualified(pair[1].Entry.Member), group.Key!));`n }`n"
|
||||
New = ""
|
||||
},
|
||||
[pscustomobject]@{
|
||||
Id = 'CACHE-D10-Loosening'
|
||||
Rule = 'D10: the collision groups by IndexAlias, not by Member.Name (which would refuse nothing HiddenMemberMessage does not already refuse).'
|
||||
Mutant = 'loosening'
|
||||
Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.DuplicateIndexAliasOnTwoDifferentlyNamedMembersIsRefusedByName'
|
||||
Old = '.GroupBy(pair => pair.Alias, StringComparer.Ordinal)'
|
||||
New = '.GroupBy(pair => pair.Entry.Member.Name, StringComparer.Ordinal)'
|
||||
},
|
||||
[pscustomobject]@{
|
||||
Id = 'CACHE-D11-Revert'
|
||||
Rule = 'D11: a dictionary reference is walked as edges keyed by CultRecordRef, not skipped as an unrecognised many-shape.'
|
||||
Mutant = 'revert'
|
||||
Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.ReferencesOfEnumeratesADictionaryReferenceAsEdgesCarryingItsValues'
|
||||
Old = 'if (TryGetDictionaryRefTypes(memberType, out var keyProperty, out var valueProperty))'
|
||||
New = 'if (false && TryGetDictionaryRefTypes(memberType, out var keyProperty, out var valueProperty))'
|
||||
},
|
||||
[pscustomobject]@{
|
||||
Id = 'CACHE-D11-Loosening'
|
||||
Rule = 'D11/S19: a dictionary reference edge carries its value as payload, not null (two entries with different payload bytes must compare unequal).'
|
||||
Mutant = 'loosening'
|
||||
Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.ReferencesOfEnumeratesADictionaryReferenceAsEdgesCarryingItsValues'
|
||||
Old = 'results.Add((reference.Key, valueProperty.GetValue(entry)));'
|
||||
New = 'results.Add((reference.Key, (object?)null));'
|
||||
}
|
||||
)
|
||||
|
||||
$exitCode = 0
|
||||
foreach ($entry in $entries) {
|
||||
Write-Host "--- $($entry.Id) ($($entry.Mutant)): $($entry.Rule)"
|
||||
$text = $utf8.GetString((Read-Bytes $target))
|
||||
$old = $entry.Old -replace "`r`n", "`n"
|
||||
$normalized = $text -replace "`r`n", "`n"
|
||||
$count = 0
|
||||
$at = $normalized.IndexOf($old, [System.StringComparison]::Ordinal)
|
||||
while ($at -ge 0) { $count++; $at = $normalized.IndexOf($old, $at + 1, [System.StringComparison]::Ordinal) }
|
||||
if ($count -ne 1) {
|
||||
throw "$($entry.Id): anchor matches $count times, expected exactly 1."
|
||||
}
|
||||
$mutatedNormalized = $normalized.Replace($old, ($entry.New -replace "`r`n", "`n"))
|
||||
$eol = if ($text.Contains("`r`n")) { "`r`n" } else { "`n" }
|
||||
$mutatedText = $mutatedNormalized -replace "`n", $eol
|
||||
|
||||
$originalBytes = Read-Bytes $target
|
||||
[System.IO.File]::WriteAllBytes($sidecar, $originalBytes)
|
||||
$verdict = 'NO VERDICT'
|
||||
try {
|
||||
Write-Text $target $mutatedText
|
||||
$run = Run-Tests $entry.Killer
|
||||
if (-not $run.Compiled) {
|
||||
$verdict = 'NO VERDICT (did not compile)'
|
||||
}
|
||||
elseif ($run.Failed -ge 1) {
|
||||
$verdict = 'KILLED'
|
||||
}
|
||||
else {
|
||||
$verdict = 'SURVIVED'
|
||||
}
|
||||
}
|
||||
finally {
|
||||
[System.IO.File]::WriteAllBytes($target, $originalBytes)
|
||||
if ((Get-BytesHash (Read-Bytes $target)) -ne $originalHash) {
|
||||
Write-Host "RESTORE FAILED: $target not restored; original SHA-256 $originalHash kept in $sidecar."
|
||||
throw "restore failed for $($entry.Id)"
|
||||
}
|
||||
Remove-Item -LiteralPath $sidecar -Force
|
||||
}
|
||||
Write-Host "$($entry.Id): $verdict"
|
||||
if ($verdict -ne 'KILLED') { $exitCode = 1 }
|
||||
}
|
||||
|
||||
# Restore the suite to green once more as a final sanity check.
|
||||
$final = Run-Tests 'FullyQualifiedName~CultDocumentSelectionSurfaceTests'
|
||||
if (-not $final.Green) { throw "post-run sanity check: the caching suite is not green after restore (passed=$($final.Passed) failed=$($final.Failed))." }
|
||||
Write-Host "Final: green, $($final.Passed) passed"
|
||||
|
||||
exit $exitCode
|
||||
|
|
@ -109,6 +109,46 @@ namespace GameCult.Caching
|
|||
public CultSchemaMigrationReport Report { get; }
|
||||
}
|
||||
|
||||
// A declared member's addressing shape, read-only: what a caller outside GameCult.Caching
|
||||
// (the CultNet selection evaluator, chiefly) is allowed to know about a member without
|
||||
// reflecting over the document itself. Built from the same declarations CultIndexAttribute
|
||||
// and CultReferenceAttribute already recorded; carries no value.
|
||||
public readonly struct CultDocumentMemberView
|
||||
{
|
||||
internal CultDocumentMemberView(
|
||||
string memberName,
|
||||
int slot,
|
||||
string? indexAlias,
|
||||
bool isName,
|
||||
bool isReference,
|
||||
bool isMany,
|
||||
bool isNumeric,
|
||||
Type? targetType)
|
||||
{
|
||||
MemberName = memberName;
|
||||
Slot = slot;
|
||||
IndexAlias = indexAlias;
|
||||
IsName = isName;
|
||||
IsReference = isReference;
|
||||
IsMany = isMany;
|
||||
IsNumeric = isNumeric;
|
||||
TargetType = targetType;
|
||||
}
|
||||
|
||||
public string MemberName { get; }
|
||||
public int Slot { get; }
|
||||
public string? IndexAlias { get; }
|
||||
public bool IsName { get; }
|
||||
public bool IsReference { get; }
|
||||
public bool IsMany { get; }
|
||||
public bool IsNumeric { get; }
|
||||
|
||||
// The reference's declared target: the type CultReferenceAttribute names, or CultRecordRef<T>'s T.
|
||||
// Null when the member is not a reference. May be abstract (D9): the leaf set is resolved at
|
||||
// evaluation by CultDocumentRegistry.ResolveTargetLeaves, never persisted here or in the catalog.
|
||||
public Type? TargetType { get; }
|
||||
}
|
||||
|
||||
public sealed class CultDocumentDescriptor
|
||||
{
|
||||
internal CultDocumentDescriptor(
|
||||
|
|
@ -122,7 +162,8 @@ namespace GameCult.Caching
|
|||
string? nameMember,
|
||||
Func<object, string?>? nameAccessor,
|
||||
IReadOnlyDictionary<string, Func<object, string>> indexAccessors,
|
||||
IReadOnlyList<CultDocumentMemberDescriptor> members)
|
||||
IReadOnlyList<CultDocumentMemberDescriptor> members,
|
||||
IReadOnlyList<CultDocumentRegistry.PersistedMember> richMembers)
|
||||
{
|
||||
DocumentType = documentType;
|
||||
SchemaName = schemaName;
|
||||
|
|
@ -135,6 +176,18 @@ namespace GameCult.Caching
|
|||
NameAccessor = nameAccessor;
|
||||
IndexAccessors = indexAccessors;
|
||||
Members = members;
|
||||
RichMembers = richMembers;
|
||||
DeclaredMembers = richMembers
|
||||
.Select(member => new CultDocumentMemberView(
|
||||
member.Member.Name,
|
||||
member.Slot,
|
||||
member.IndexAlias,
|
||||
member.IsName,
|
||||
member.IsReference,
|
||||
member.IsMany,
|
||||
member.IsNumeric,
|
||||
member.TargetType))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public Type DocumentType { get; }
|
||||
|
|
@ -148,6 +201,80 @@ namespace GameCult.Caching
|
|||
internal Func<object, string?>? NameAccessor { get; }
|
||||
internal IReadOnlyDictionary<string, Func<object, string>> IndexAccessors { get; }
|
||||
internal IReadOnlyList<CultDocumentMemberDescriptor> Members { get; }
|
||||
internal IReadOnlyList<CultDocumentRegistry.PersistedMember> RichMembers { get; }
|
||||
|
||||
// The public read surface (CultNet selection cut 1, section 6): declared addressing, and the
|
||||
// three ways to read a value through it. GameCult.Networking has no InternalsVisibleTo here on
|
||||
// purpose - this is the only door, so nothing outside the cache decides what a member means.
|
||||
public IReadOnlyList<CultDocumentMemberView> DeclaredMembers { get; }
|
||||
|
||||
public bool TryGetIndexValue(object document, string alias, out string? value)
|
||||
{
|
||||
if (document == null) throw new ArgumentNullException(nameof(document));
|
||||
if (string.IsNullOrEmpty(alias))
|
||||
{
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var member in RichMembers)
|
||||
{
|
||||
if (string.Equals(member.IndexAlias, alias, StringComparison.Ordinal))
|
||||
{
|
||||
value = member.GetterNullable(document);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetIndexNumber(object document, string alias, out double value)
|
||||
{
|
||||
if (document == null) throw new ArgumentNullException(nameof(document));
|
||||
if (!string.IsNullOrEmpty(alias))
|
||||
{
|
||||
foreach (var member in RichMembers)
|
||||
{
|
||||
if (string.Equals(member.IndexAlias, alias, StringComparison.Ordinal) && member.IsNumeric)
|
||||
{
|
||||
var number = member.NumberOf(document);
|
||||
if (number.HasValue)
|
||||
{
|
||||
value = number.Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
// The edges a declared reference member carries: one per CultRecordRef<T>, one per element of
|
||||
// an IEnumerable<CultRecordRef<T>>, one per key of an IDictionary<CultRecordRef<T>, V> with the
|
||||
// value as the payload (D11). role is the member's index alias, or its name when it has none.
|
||||
public IEnumerable<(CultRecordKey Target, object? Payload)> ReferencesOf(object document, string role)
|
||||
{
|
||||
if (document == null) throw new ArgumentNullException(nameof(document));
|
||||
if (string.IsNullOrEmpty(role)) return Array.Empty<(CultRecordKey, object?)>();
|
||||
|
||||
foreach (var member in RichMembers)
|
||||
{
|
||||
if (!member.IsReference) continue;
|
||||
var memberRole = member.IndexAlias ?? member.Member.Name;
|
||||
if (string.Equals(memberRole, role, StringComparison.Ordinal))
|
||||
{
|
||||
return member.ReferenceEnumerator!(document);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.Empty<(CultRecordKey, object?)>();
|
||||
}
|
||||
|
||||
public CultSchemaCatalogEntry ToCatalogEntry()
|
||||
{
|
||||
|
|
@ -254,6 +381,15 @@ namespace GameCult.Caching
|
|||
public IEnumerable<CultDocumentDescriptor> AllDescriptors =>
|
||||
_indexes.ByType.Values.OrderBy(d => d.SchemaName, StringComparer.Ordinal);
|
||||
|
||||
// D9: a reference's target set is every registered leaf assignable to its declared target type,
|
||||
// computed against the registry as it stands right now - never cached, never persisted. A leaf
|
||||
// registered after some other descriptor was built must still show up here.
|
||||
public IReadOnlyList<CultDocumentDescriptor> ResolveTargetLeaves(Type targetType)
|
||||
{
|
||||
if (targetType == null) throw new ArgumentNullException(nameof(targetType));
|
||||
return AllDescriptors.Where(d => targetType.IsAssignableFrom(d.DocumentType)).ToArray();
|
||||
}
|
||||
|
||||
public void Refresh()
|
||||
{
|
||||
lock (_registrationGate)
|
||||
|
|
@ -495,7 +631,8 @@ namespace GameCult.Caching
|
|||
nameMember?.Member.Name,
|
||||
nameMember?.GetterNullable,
|
||||
indexAccessors,
|
||||
descriptorMembers);
|
||||
descriptorMembers,
|
||||
members);
|
||||
}
|
||||
|
||||
private static string BuildSemanticFingerprint(
|
||||
|
|
@ -944,6 +1081,38 @@ namespace GameCult.Caching
|
|||
rejections.Add(HiddenMemberMessage(type.Name, Qualified(pair[0].Member), Qualified(pair[1].Member)));
|
||||
}
|
||||
|
||||
// D10: two differently-named members that independently declare the same index alias. A
|
||||
// hiding member sharing one slot is already caught above; this is two distinct members whose
|
||||
// alias map entry (BuildDescriptor's indexAccessors) would otherwise collide silently.
|
||||
foreach (var group in keyed
|
||||
.Select(entry => (Entry: entry, Alias: ResolveIndexAlias(entry.Member)))
|
||||
.Where(pair => pair.Alias != null)
|
||||
.GroupBy(pair => pair.Alias, StringComparer.Ordinal)
|
||||
.Where(group => group.Select(pair => pair.Entry.Member.Name).Distinct().Count() > 1)
|
||||
.OrderBy(group => group.Key, StringComparer.Ordinal))
|
||||
{
|
||||
var pair = group.Take(2).ToArray();
|
||||
rejections.Add(DuplicateIndexAliasMessage(type.Name, Qualified(pair[0].Entry.Member), Qualified(pair[1].Entry.Member), group.Key!));
|
||||
}
|
||||
|
||||
// D11: a member declared as a reference (by [CultReference] or a bare CultRecordRef<T> type)
|
||||
// whose CLR shape the cache cannot walk. many:false needs CultRecordRef<T> itself; many:true
|
||||
// needs IEnumerable<CultRecordRef<T>> or IDictionary<CultRecordRef<T>, V>. A declaration the
|
||||
// cache cannot read is refused here, not answered as an unreadable no-op reference later.
|
||||
foreach (var candidate in keyed)
|
||||
{
|
||||
var referenceAttribute = candidate.Member.GetCustomAttribute<CultReferenceAttribute>();
|
||||
var isBareRecordRef = IsCultRecordRefType(candidate.MemberType);
|
||||
if (referenceAttribute == null && !isBareRecordRef)
|
||||
continue;
|
||||
var many = referenceAttribute?.Many ?? false;
|
||||
var supported = many
|
||||
? IsEnumerableOfRecordRef(candidate.MemberType) || TryGetDictionaryRefTypes(candidate.MemberType, out _, out _)
|
||||
: isBareRecordRef;
|
||||
if (!supported)
|
||||
rejections.Add(UnsupportedReferenceShapeMessage(type.Name, Qualified(candidate.Member)));
|
||||
}
|
||||
|
||||
if (rejections.Count > 0)
|
||||
throw new InvalidOperationException(rejections[0]);
|
||||
|
||||
|
|
@ -992,6 +1161,74 @@ namespace GameCult.Caching
|
|||
|
||||
private static string Qualified(MemberInfo member) => member.DeclaringType!.Name.Split('`')[0] + "." + member.Name;
|
||||
|
||||
// Shared by DiscoverMembers' D10/D11 rejections and PersistedMember.FromMember, so registration
|
||||
// refuses exactly the shapes the accessor it builds afterward could not have walked anyway.
|
||||
private static string? ResolveIndexAlias(MemberInfo member)
|
||||
{
|
||||
var indexAttribute = member.GetCustomAttribute<CultIndexAttribute>();
|
||||
if (indexAttribute == null)
|
||||
return null;
|
||||
return string.IsNullOrWhiteSpace(indexAttribute.Alias) ? member.Name : indexAttribute.Alias;
|
||||
}
|
||||
|
||||
private static bool IsCultRecordRefType(Type type) =>
|
||||
type.IsGenericType && type.GetGenericTypeDefinition() == typeof(CultRecordRef<>);
|
||||
|
||||
private static bool IsEnumerableOfRecordRef(Type type)
|
||||
{
|
||||
foreach (var candidate in type.GetInterfaces().Append(type))
|
||||
{
|
||||
if (candidate.IsGenericType
|
||||
&& candidate.GetGenericTypeDefinition() == typeof(IEnumerable<>)
|
||||
&& IsCultRecordRefType(candidate.GetGenericArguments()[0]))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryGetDictionaryRefTypes(Type type, out PropertyInfo keyProperty, out PropertyInfo valueProperty)
|
||||
{
|
||||
foreach (var candidate in type.GetInterfaces().Append(type))
|
||||
{
|
||||
if (!candidate.IsGenericType || candidate.GetGenericTypeDefinition() != typeof(IDictionary<,>))
|
||||
continue;
|
||||
var arguments = candidate.GetGenericArguments();
|
||||
if (!IsCultRecordRefType(arguments[0]))
|
||||
continue;
|
||||
var pairType = typeof(KeyValuePair<,>).MakeGenericType(arguments[0], arguments[1]);
|
||||
keyProperty = pairType.GetProperty("Key")!;
|
||||
valueProperty = pairType.GetProperty("Value")!;
|
||||
return true;
|
||||
}
|
||||
|
||||
keyProperty = null!;
|
||||
valueProperty = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static readonly HashSet<Type> NumericClrTypes = new()
|
||||
{
|
||||
typeof(sbyte), typeof(byte), typeof(short), typeof(ushort),
|
||||
typeof(int), typeof(uint), typeof(long), typeof(ulong),
|
||||
typeof(float), typeof(double), typeof(decimal)
|
||||
};
|
||||
|
||||
// D8's closed CLR numeric set: the sole authority for whether a comparison predicate may reach
|
||||
// a member. The TypeName string persisted in the catalog is generated from this set (via
|
||||
// CultSchemaTypeNames.FromType) and must never be hand-checked against separately - S22 pins it.
|
||||
private static bool IsNumericClrType(Type type)
|
||||
{
|
||||
var underlying = Nullable.GetUnderlyingType(type) ?? type;
|
||||
return NumericClrTypes.Contains(underlying);
|
||||
}
|
||||
|
||||
private static string DuplicateIndexAliasMessage(string documentTypeName, string first, string second, string alias) =>
|
||||
$"Cult document {documentTypeName} members {first} and {second} both declare index alias \"{alias}\"; an index alias must name one member.";
|
||||
|
||||
private static string UnsupportedReferenceShapeMessage(string documentTypeName, string member) =>
|
||||
$"Cult document {documentTypeName} member {member} declares a reference the cache cannot walk; many:false needs CultRecordRef<T>, many:true needs IEnumerable<CultRecordRef<T>> or IDictionary<CultRecordRef<T>, V>.";
|
||||
|
||||
private static string NotMessagePackObjectMessage(string documentTypeName) =>
|
||||
$"Cult document {documentTypeName} is not a MessagePack object; add [MessagePackObject] so MessagePack serializes its [Key(n)] members.";
|
||||
|
||||
|
|
@ -1047,7 +1284,9 @@ namespace GameCult.Caching
|
|||
return "sha256:" + BitConverter.ToString(bytes).Replace("-", string.Empty).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private sealed class PersistedMember
|
||||
// internal, not private: CultDocumentDescriptor (a separate top-level class) reads RichMembers
|
||||
// through this type to build the public DeclaredMembers view and the three value accessors.
|
||||
internal sealed class PersistedMember
|
||||
{
|
||||
public MemberInfo Member { get; set; } = default!;
|
||||
public Type MemberType { get; set; } = default!;
|
||||
|
|
@ -1057,9 +1296,25 @@ namespace GameCult.Caching
|
|||
public bool IsReference { get; set; }
|
||||
public bool IsMany { get; set; }
|
||||
public string? TargetSchemaName { get; set; }
|
||||
|
||||
// The reference's declared target Type, held alongside TargetSchemaName (which is null for
|
||||
// an abstract target - D9). CultDocumentRegistry.ResolveTargetLeaves needs the Type, not the
|
||||
// name, to compute the leaf set by assignability.
|
||||
public Type? TargetType { get; set; }
|
||||
public bool IsNumeric { get; set; }
|
||||
public Func<object, string> Getter { get; set; } = default!;
|
||||
public Func<object, string?> GetterNullable { get; set; } = default!;
|
||||
|
||||
// D8: non-null exactly for the closed CLR numeric set (Nullable<T> unwrapped). Convert.ToDouble
|
||||
// never loses a value CultCache's own supported numeric types can hold.
|
||||
public Func<object, double?> NumberOf { get; set; } = default!;
|
||||
|
||||
// D11: null for a non-reference member; otherwise yields (target key, payload) per declared
|
||||
// shape - one entry for a bare reference, one per element for a many-enumerable, one per key
|
||||
// with its value as payload for a many-dictionary. Registration already refused every other
|
||||
// shape (DiscoverMembers), so this is never called against something it cannot walk.
|
||||
public Func<object, IEnumerable<(CultRecordKey Target, object? Payload)>>? ReferenceEnumerator { get; set; }
|
||||
|
||||
public static PersistedMember FromMember(
|
||||
MemberInfo member,
|
||||
Type memberType,
|
||||
|
|
@ -1069,6 +1324,8 @@ namespace GameCult.Caching
|
|||
var referenceAttribute = member.GetCustomAttribute<CultReferenceAttribute>();
|
||||
var targetType = ResolveReferenceTarget(memberType, referenceAttribute?.TargetType);
|
||||
var targetSchemaName = targetType?.GetCustomAttribute<CultDocumentAttribute>()?.SchemaName;
|
||||
var isReference = targetType != null || referenceAttribute != null;
|
||||
var many = referenceAttribute?.Many ?? false;
|
||||
return new PersistedMember
|
||||
{
|
||||
Member = member,
|
||||
|
|
@ -1076,25 +1333,65 @@ namespace GameCult.Caching
|
|||
Slot = slot,
|
||||
IsName = member.GetCustomAttribute<CultNameAttribute>() != null,
|
||||
IndexAlias = ResolveIndexAlias(member),
|
||||
IsReference = targetType != null || referenceAttribute != null,
|
||||
IsMany = referenceAttribute?.Many ?? false,
|
||||
IsReference = isReference,
|
||||
IsMany = many,
|
||||
TargetSchemaName = targetSchemaName,
|
||||
TargetType = targetType,
|
||||
IsNumeric = IsNumericClrType(memberType),
|
||||
Getter = document => getValue(document)?.ToString() ?? string.Empty,
|
||||
GetterNullable = document => getValue(document)?.ToString()
|
||||
GetterNullable = document => getValue(document)?.ToString(),
|
||||
NumberOf = document =>
|
||||
{
|
||||
var raw = getValue(document);
|
||||
return raw == null ? null : (double?)Convert.ToDouble(raw, CultureInfo.InvariantCulture);
|
||||
},
|
||||
ReferenceEnumerator = isReference ? BuildReferenceEnumerator(memberType, many, getValue) : null
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ResolveIndexAlias(MemberInfo member)
|
||||
// Registration (DiscoverMembers' D11 check) already refused every shape but the three this
|
||||
// builds an enumerator for; this does not re-refuse, it only classifies which of the three.
|
||||
private static Func<object, IEnumerable<(CultRecordKey Target, object? Payload)>> BuildReferenceEnumerator(
|
||||
Type memberType, bool many, Func<object, object?> getValue)
|
||||
{
|
||||
var indexAttribute = member.GetCustomAttribute<CultIndexAttribute>();
|
||||
if (indexAttribute == null)
|
||||
if (!many)
|
||||
{
|
||||
return null;
|
||||
return document =>
|
||||
getValue(document) is ICultRecordRef reference
|
||||
? new[] { (reference.Key, (object?)null) }
|
||||
: Array.Empty<(CultRecordKey, object?)>();
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(indexAttribute.Alias)
|
||||
? member.Name
|
||||
: indexAttribute.Alias;
|
||||
if (TryGetDictionaryRefTypes(memberType, out var keyProperty, out var valueProperty))
|
||||
{
|
||||
return document =>
|
||||
{
|
||||
if (getValue(document) is not System.Collections.IEnumerable sequence)
|
||||
return Array.Empty<(CultRecordKey, object?)>();
|
||||
var results = new List<(CultRecordKey, object?)>();
|
||||
foreach (var entry in sequence)
|
||||
{
|
||||
if (keyProperty.GetValue(entry) is ICultRecordRef reference)
|
||||
results.Add((reference.Key, valueProperty.GetValue(entry)));
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
}
|
||||
|
||||
return document =>
|
||||
{
|
||||
if (getValue(document) is not System.Collections.IEnumerable sequence)
|
||||
return Array.Empty<(CultRecordKey, object?)>();
|
||||
var results = new List<(CultRecordKey, object?)>();
|
||||
foreach (var entry in sequence)
|
||||
{
|
||||
if (entry is ICultRecordRef reference)
|
||||
results.Add((reference.Key, (object?)null));
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
}
|
||||
|
||||
private static Type? ResolveReferenceTarget(Type memberType, Type? explicitTarget)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,274 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Reflection.Emit;
|
||||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GameCult.Caching.Tests
|
||||
{
|
||||
// CultNet typed selection, Cut 1, commit 0: the cache's public read surface over declared members
|
||||
// (docs/cultnet-selection-cut.md, section 6). These tests are independent of GameCult.Networking -
|
||||
// the evaluator that will consume this surface does not exist yet in this commit.
|
||||
public sealed class CultDocumentSelectionSurfaceTests
|
||||
{
|
||||
// S19: a dictionary reference is enumerated as edges carrying its values, not merely its keys.
|
||||
[Test]
|
||||
public void ReferencesOfEnumeratesADictionaryReferenceAsEdgesCarryingItsValues()
|
||||
{
|
||||
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB), typeof(FixtureCiter) });
|
||||
var citer = registry.GetRequired<FixtureCiter>();
|
||||
var a1 = new CultRecordRef<FixtureLeafA>(new CultRecordKey("leaf-a-1"));
|
||||
var a2 = new CultRecordRef<FixtureLeafA>(new CultRecordKey("leaf-a-2"));
|
||||
var document = new FixtureCiter
|
||||
{
|
||||
Components = new Dictionary<CultRecordRef<FixtureLeafA>, float> { [a1] = 1.5f, [a2] = 2.5f }
|
||||
};
|
||||
|
||||
var edges = citer.ReferencesOf(document, "Components").ToArray();
|
||||
|
||||
Assert.That(edges.Select(edge => edge.Target.Value).OrderBy(v => v, StringComparer.Ordinal),
|
||||
Is.EqualTo(new[] { "leaf-a-1", "leaf-a-2" }));
|
||||
Assert.That(edges.Single(edge => edge.Target.Value == "leaf-a-1").Payload, Is.EqualTo(1.5f));
|
||||
Assert.That(edges.Single(edge => edge.Target.Value == "leaf-a-2").Payload, Is.EqualTo(2.5f));
|
||||
// Two entries with different payload bytes: a shape-only assertion (keys enumerated, values
|
||||
// dropped) would pass without this distinguishing the payloads (spec section 10, S19).
|
||||
Assert.That(edges.Select(edge => edge.Payload).Distinct().Count(), Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReferencesOfEnumeratesAManyListReferenceWithNoPayload()
|
||||
{
|
||||
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB), typeof(FixtureCiter) });
|
||||
var citer = registry.GetRequired<FixtureCiter>();
|
||||
var document = new FixtureCiter
|
||||
{
|
||||
Related = new List<CultRecordRef<FixtureLeafA>>
|
||||
{
|
||||
new(new CultRecordKey("leaf-a-3")),
|
||||
new(new CultRecordKey("leaf-a-4"))
|
||||
}
|
||||
};
|
||||
|
||||
var edges = citer.ReferencesOf(document, "Related").ToArray();
|
||||
|
||||
Assert.That(edges.Select(edge => edge.Target.Value), Is.EqualTo(new[] { "leaf-a-3", "leaf-a-4" }));
|
||||
Assert.That(edges.All(edge => edge.Payload == null), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReferencesOfEnumeratesASingleReferenceToAnAbstractTarget()
|
||||
{
|
||||
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB), typeof(FixtureCiter) });
|
||||
var citer = registry.GetRequired<FixtureCiter>();
|
||||
var document = new FixtureCiter { Design = new CultRecordRef<FixtureMiddle>(new CultRecordKey("leaf-b-1")) };
|
||||
|
||||
var edges = citer.ReferencesOf(document, "Design").ToArray();
|
||||
|
||||
Assert.That(edges.Single().Target.Value, Is.EqualTo("leaf-b-1"));
|
||||
Assert.That(edges.Single().Payload, Is.Null);
|
||||
}
|
||||
|
||||
// D9: the abstract middle carries no [CultDocument] and no catalog entry (TargetSchemaName is
|
||||
// null for exactly this reference), so the leaf set must be resolved by assignability against
|
||||
// the live registry, not read off a persisted name.
|
||||
[Test]
|
||||
public void ResolveTargetLeavesFindsBothLeavesUnderAnAbstractMiddleWithNoSchemaOfItsOwn()
|
||||
{
|
||||
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB), typeof(FixtureCiter) });
|
||||
var citer = registry.GetRequired<FixtureCiter>();
|
||||
var designMember = citer.DeclaredMembers.Single(member => member.MemberName == nameof(FixtureCiter.Design));
|
||||
|
||||
Assert.That(designMember.TargetType, Is.EqualTo(typeof(FixtureMiddle)));
|
||||
Assert.That(typeof(FixtureMiddle).GetCustomAttribute<CultDocumentAttribute>(), Is.Null);
|
||||
|
||||
var leaves = registry.ResolveTargetLeaves(designMember.TargetType!);
|
||||
|
||||
Assert.That(leaves.Select(d => d.DocumentType).OrderBy(t => t.Name),
|
||||
Is.EqualTo(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB) }.OrderBy(t => t.Name)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ResolveTargetLeavesSeesALeafRegisteredAfterAnEarlierDescriptorWasBuilt()
|
||||
{
|
||||
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureCiter), typeof(FixtureLeafA) });
|
||||
var citer = registry.GetRequired<FixtureCiter>();
|
||||
var designMember = citer.DeclaredMembers.Single(member => member.MemberName == nameof(FixtureCiter.Design));
|
||||
|
||||
var before = registry.ResolveTargetLeaves(designMember.TargetType!);
|
||||
Assert.That(before.Select(d => d.DocumentType), Does.Not.Contain(typeof(FixtureLeafB)));
|
||||
|
||||
registry.GetRequired<FixtureLeafB>();
|
||||
var after = registry.ResolveTargetLeaves(designMember.TargetType!);
|
||||
Assert.That(after.Select(d => d.DocumentType), Does.Contain(typeof(FixtureLeafB)));
|
||||
}
|
||||
|
||||
// D8's inherited-alias case, read through the public surface: mass is declared once on the
|
||||
// abstract middle and reachable on both leaves.
|
||||
[Test]
|
||||
public void TryGetIndexValueAndNumberReadAnInheritedAliasOnEachLeaf()
|
||||
{
|
||||
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB) });
|
||||
var leafA = registry.GetRequired<FixtureLeafA>();
|
||||
var documentA = new FixtureLeafA { Name = "sword", Mass = 3.5f };
|
||||
|
||||
Assert.That(leafA.TryGetIndexValue(documentA, "mass", out var text), Is.True);
|
||||
Assert.That(text, Is.EqualTo("3.5"));
|
||||
Assert.That(leafA.TryGetIndexNumber(documentA, "mass", out var number), Is.True);
|
||||
Assert.That(number, Is.EqualTo(3.5d));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryGetIndexValueRefusesAnUndeclaredAlias()
|
||||
{
|
||||
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA) });
|
||||
var leafA = registry.GetRequired<FixtureLeafA>();
|
||||
|
||||
Assert.That(leafA.TryGetIndexValue(new FixtureLeafA(), "not_declared", out _), Is.False);
|
||||
}
|
||||
|
||||
// S22: IsNumeric is derived from the CLR set (sbyte..decimal, Nullable<T> unwrapped) and must
|
||||
// agree for a nullable numeric member and a decimal member - the two authorities D8 names.
|
||||
[Test]
|
||||
public void IsNumericAgreesForANullableNumericMemberAndADecimalMember()
|
||||
{
|
||||
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafB) });
|
||||
var leafB = registry.GetRequired<FixtureLeafB>();
|
||||
var weight = leafB.DeclaredMembers.Single(member => member.MemberName == nameof(FixtureLeafB.Weight));
|
||||
|
||||
Assert.That(weight.IsNumeric, Is.True);
|
||||
var document = new FixtureLeafB { Weight = 4.25m };
|
||||
Assert.That(leafB.TryGetIndexNumber(document, "weight", out var number), Is.True);
|
||||
Assert.That(number, Is.EqualTo(4.25d));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsNumericIsFalseForAStringAlias()
|
||||
{
|
||||
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA) });
|
||||
var leafA = registry.GetRequired<FixtureLeafA>();
|
||||
var name = leafA.DeclaredMembers.Single(member => member.MemberName == nameof(FixtureLeafA.Name));
|
||||
|
||||
Assert.That(name.IsNumeric, Is.False);
|
||||
}
|
||||
|
||||
// D10 and D11 registration refusals: emitted into a dynamic assembly, invisible to registry
|
||||
// discovery by attribute scan, exactly as DocumentShapeTests does for every other shape refusal.
|
||||
private static readonly ModuleBuilder Rejected = AssemblyBuilder
|
||||
.DefineDynamicAssembly(new AssemblyName("RejectedSelectionShapes"), AssemblyBuilderAccess.Run)
|
||||
.DefineDynamicModule("RejectedSelectionShapes");
|
||||
|
||||
[Test]
|
||||
public void DuplicateIndexAliasOnTwoDifferentlyNamedMembersIsRefusedByName()
|
||||
{
|
||||
var type = Document("DupAlias");
|
||||
Field(type, "First", typeof(string), Key(0), IndexAttr("shared"));
|
||||
Field(type, "Second", typeof(string), Key(1), IndexAttr("shared"));
|
||||
var message = Assert.Throws<InvalidOperationException>(() => CultDocumentRegistry.ForTypes(new[] { type.CreateType()! }))!.Message;
|
||||
|
||||
Assert.That(message, Does.Contain("DupAlias"));
|
||||
Assert.That(message, Does.Contain("First"));
|
||||
Assert.That(message, Does.Contain("Second"));
|
||||
Assert.That(message, Does.Contain("shared"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ManyReferenceOfAnElementTypeThatIsNotACultRecordRefIsRefused()
|
||||
{
|
||||
var type = Document("BadMany");
|
||||
Field(type, "Values", typeof(List<int>), Key(0), ReferenceAttr(null, many: true));
|
||||
var message = Assert.Throws<InvalidOperationException>(() => CultDocumentRegistry.ForTypes(new[] { type.CreateType()! }))!.Message;
|
||||
|
||||
Assert.That(message, Does.Contain("BadMany"));
|
||||
Assert.That(message, Does.Contain("Values"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SingleReferenceWhoseMemberTypeIsNotACultRecordRefIsRefused()
|
||||
{
|
||||
var type = Document("BadSingle");
|
||||
Field(type, "Target", typeof(int), Key(0), ReferenceAttr(typeof(FixtureLeafA), many: false));
|
||||
var message = Assert.Throws<InvalidOperationException>(() => CultDocumentRegistry.ForTypes(new[] { type.CreateType()! }))!.Message;
|
||||
|
||||
Assert.That(message, Does.Contain("BadSingle"));
|
||||
Assert.That(message, Does.Contain("Target"));
|
||||
}
|
||||
|
||||
private static TypeBuilder Document(string name)
|
||||
{
|
||||
var type = Rejected.DefineType(name, TypeAttributes.Public);
|
||||
type.SetCustomAttribute(new CustomAttributeBuilder(
|
||||
typeof(CultDocumentAttribute).GetConstructor(new[] { typeof(string), typeof(string) })!,
|
||||
new object[] { "selection.reject." + name, "selection.reject." + name + ".v1" }));
|
||||
type.SetCustomAttribute(new CustomAttributeBuilder(typeof(MessagePackObjectAttribute).GetConstructor(new[] { typeof(bool) })!, new object[] { false }));
|
||||
return type;
|
||||
}
|
||||
|
||||
private static void Field(TypeBuilder type, string name, Type fieldType, params CustomAttributeBuilder[] attributes)
|
||||
{
|
||||
var field = type.DefineField(name, fieldType, FieldAttributes.Public);
|
||||
foreach (var attribute in attributes) field.SetCustomAttribute(attribute);
|
||||
}
|
||||
|
||||
private static CustomAttributeBuilder Key(int slot) =>
|
||||
new(typeof(KeyAttribute).GetConstructor(new[] { typeof(int) })!, new object[] { slot });
|
||||
|
||||
private static CustomAttributeBuilder IndexAttr(string alias) =>
|
||||
new(typeof(CultIndexAttribute).GetConstructor(new[] { typeof(string) })!, new object?[] { alias });
|
||||
|
||||
private static CustomAttributeBuilder ReferenceAttr(Type? targetType, bool many) =>
|
||||
new(typeof(CultReferenceAttribute).GetConstructor(new[] { typeof(Type), typeof(bool) })!, new object?[] { targetType, many });
|
||||
|
||||
public abstract class FixtureMiddle
|
||||
{
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string Name = string.Empty;
|
||||
|
||||
[Key(1)]
|
||||
[CultIndex("mass")]
|
||||
public float Mass;
|
||||
}
|
||||
|
||||
[CultDocument("selection.fixture.leaf_a", "selection.fixture.leaf_a.v1")]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
public sealed class FixtureLeafA : FixtureMiddle
|
||||
{
|
||||
[Key(2)]
|
||||
public int Extra;
|
||||
}
|
||||
|
||||
[CultDocument("selection.fixture.leaf_b", "selection.fixture.leaf_b.v1")]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
public sealed class FixtureLeafB : FixtureMiddle
|
||||
{
|
||||
[Key(2)]
|
||||
[CultIndex("weight")]
|
||||
public decimal? Weight;
|
||||
}
|
||||
|
||||
[CultDocument("selection.fixture.citer", "selection.fixture.citer.v1")]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
public sealed class FixtureCiter
|
||||
{
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string Name = string.Empty;
|
||||
|
||||
[Key(1)]
|
||||
[CultReference(typeof(FixtureMiddle))]
|
||||
public CultRecordRef<FixtureMiddle> Design;
|
||||
|
||||
[Key(2)]
|
||||
[CultReference(typeof(FixtureLeafA), many: true)]
|
||||
public Dictionary<CultRecordRef<FixtureLeafA>, float> Components = new();
|
||||
|
||||
[Key(3)]
|
||||
[CultReference(typeof(FixtureLeafA), many: true)]
|
||||
public List<CultRecordRef<FixtureLeafA>> Related = new();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue