Add the engine-free CultCache inspection model
The CultInspector* attributes move from the Unity-only GameCult.Unity.Caching runtime assembly into GameCult.Caching, so headless shared models carry them. CultInspectorAssetPath keeps a System.Type the consumer names. CultInspectorDrawer claims either a value type (open generic definitions claim every closed form) or an Attribute subclass. CultInspectorModel owns what an inspector shows and which edits it may make, with no engine reference: - members: registry catalog slots for documents, [Key] slots for other MessagePack objects, public fields for unkeyed structs; Label, Hidden, ReadOnly, Order, Range, TextArea and AssetPath metadata; assignability by the rule the registry persists by. - shapes: string, integer, float, bool, enum, composite (an unkeyed value type rebuilt through the constructor whose parameters match its members, which covers the CultMath vectors, rect and Color32 without naming them), CultRecordRef<T> with candidate records, rank-1 list or array, dictionary, union limited to declared [Union] subtypes, nested, and unsupported with a reason (multi-dimensional arrays included). - CultInspectorDrawerClaims: attribute claims, then type claims, then built-in; invalid drawers and contested claims are errors and draw nothing. - dictionary keys by serialized identity (a record reference by its key string, null and "" alike), refusal of null, empty-reference and duplicate keys, fresh keys, and collections rebuilt as new instances. - CultInspectorEdit: a codec clone of the stored document; Commit upserts the copy, so a refused upsert leaves the cached object unchanged. ICultRecordRef exposes CultRecordRef<T>.Key without T, replacing the Studio's by-name GetProperty lookup. Tests (CultInspectorModelTests): member list and metadata, claim resolution order, declared-only union choices, record-reference candidates, dictionary key refusal, refused commit leaving the cached document unchanged, and shape classification. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
b6ed9e5f83
commit
ba1119f18c
4 changed files with 983 additions and 1 deletions
|
|
@ -85,7 +85,13 @@ namespace GameCult.Caching
|
|||
public override string ToString() => Key.ToString();
|
||||
}
|
||||
|
||||
public readonly struct CultRecordRef<T>
|
||||
// The key of any CultRecordRef<T>, for code that holds one without knowing T.
|
||||
public interface ICultRecordRef
|
||||
{
|
||||
CultRecordKey Key { get; }
|
||||
}
|
||||
|
||||
public readonly struct CultRecordRef<T> : ICultRecordRef
|
||||
{
|
||||
public CultRecordRef(CultRecordKey key)
|
||||
{
|
||||
|
|
|
|||
96
src/GameCult.Caching/CultInspectorAttributes.cs
Normal file
96
src/GameCult.Caching/CultInspectorAttributes.cs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
using System;
|
||||
|
||||
namespace GameCult.Caching
|
||||
{
|
||||
// Inspection metadata for document members. Engine-free: headless models carry it, and every inspector lowering
|
||||
// (the Unity editor Studio, a runtime CultUI panel) reads it through CultInspectorModel.
|
||||
|
||||
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property | AttributeTargets.Class, Inherited = true)]
|
||||
public sealed class CultInspectorLabelAttribute : Attribute
|
||||
{
|
||||
public CultInspectorLabelAttribute(string label)
|
||||
{
|
||||
Label = label ?? string.Empty;
|
||||
}
|
||||
|
||||
public string Label { get; }
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)]
|
||||
public sealed class CultInspectorHiddenAttribute : Attribute
|
||||
{
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)]
|
||||
public sealed class CultInspectorReadOnlyAttribute : Attribute
|
||||
{
|
||||
}
|
||||
|
||||
// Lower values appear first; members without it keep their slot order.
|
||||
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)]
|
||||
public sealed class CultInspectorOrderAttribute : Attribute
|
||||
{
|
||||
public CultInspectorOrderAttribute(int order)
|
||||
{
|
||||
Order = order;
|
||||
}
|
||||
|
||||
public int Order { get; }
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)]
|
||||
public sealed class CultInspectorTextAreaAttribute : Attribute
|
||||
{
|
||||
public CultInspectorTextAreaAttribute(int minLines = 3, int maxLines = 12)
|
||||
{
|
||||
MinLines = minLines;
|
||||
MaxLines = maxLines;
|
||||
}
|
||||
|
||||
public int MinLines { get; }
|
||||
public int MaxLines { get; }
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)]
|
||||
public sealed class CultInspectorRangeAttribute : Attribute
|
||||
{
|
||||
public CultInspectorRangeAttribute(float min, float max)
|
||||
{
|
||||
Min = min;
|
||||
Max = max;
|
||||
}
|
||||
|
||||
public float Min { get; }
|
||||
public float Max { get; }
|
||||
}
|
||||
|
||||
// A string member holding an engine asset path. AssetType is the engine's asset type, named by the consumer
|
||||
// (typeof(UnityEngine.Texture2D) in a Unity assembly); a lowering that does not know the type ignores it.
|
||||
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)]
|
||||
public sealed class CultInspectorAssetPathAttribute : Attribute
|
||||
{
|
||||
public CultInspectorAssetPathAttribute(Type? assetType = null)
|
||||
{
|
||||
AssetType = assetType;
|
||||
}
|
||||
|
||||
public Type? AssetType { get; }
|
||||
}
|
||||
|
||||
// Marks a lowering's drawer class as the drawer for a claimed type. Claimed is either an Attribute subclass (the
|
||||
// drawer draws every member carrying that attribute) or a value type (every value of that type, or of any closed
|
||||
// form when Claimed is an open generic definition). Attribute claims win over type claims, which win over the
|
||||
// built-in kinds. A claim two drawers make is used by neither.
|
||||
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
|
||||
public sealed class CultInspectorDrawerAttribute : Attribute
|
||||
{
|
||||
public CultInspectorDrawerAttribute(Type claimed)
|
||||
{
|
||||
Claimed = claimed ?? throw new ArgumentNullException(nameof(claimed));
|
||||
}
|
||||
|
||||
public Type Claimed { get; }
|
||||
|
||||
public bool ClaimsAttribute => typeof(Attribute).IsAssignableFrom(Claimed);
|
||||
}
|
||||
}
|
||||
588
src/GameCult.Caching/CultInspectorModel.cs
Normal file
588
src/GameCult.Caching/CultInspectorModel.cs
Normal file
|
|
@ -0,0 +1,588 @@
|
|||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using MessagePack;
|
||||
|
||||
namespace GameCult.Caching
|
||||
{
|
||||
// The engine-free inspection model: what an inspector draws for a document and which edits it may make. Lowerings
|
||||
// (the Unity editor Studio's IMGUI, a runtime CultUI panel) own only widgets and layout; members, metadata, value
|
||||
// shapes, drawer claims and edit rules come from here.
|
||||
|
||||
public enum CultInspectorValueKind
|
||||
{
|
||||
Unsupported,
|
||||
String,
|
||||
Integer,
|
||||
Float,
|
||||
Bool,
|
||||
Enum,
|
||||
// An unkeyed value type rebuilt through its constructor (CultMath vectors, rect, Color32).
|
||||
Composite,
|
||||
RecordRef,
|
||||
List,
|
||||
Dictionary,
|
||||
Union,
|
||||
Nested
|
||||
}
|
||||
|
||||
// What the inspection attributes on one member say. A bare value has none.
|
||||
public sealed class CultInspectorMetadata
|
||||
{
|
||||
internal static readonly CultInspectorMetadata None = new CultInspectorMetadata(null);
|
||||
|
||||
internal CultInspectorMetadata(MemberInfo? member)
|
||||
{
|
||||
Attributes = member == null ? Array.Empty<Attribute>() : member.GetCustomAttributes(true).OfType<Attribute>().ToArray();
|
||||
var label = Find<CultInspectorLabelAttribute>()?.Label;
|
||||
Label = string.IsNullOrWhiteSpace(label) ? null : label;
|
||||
Hidden = Find<CultInspectorHiddenAttribute>() != null;
|
||||
ReadOnly = Find<CultInspectorReadOnlyAttribute>() != null;
|
||||
Order = Find<CultInspectorOrderAttribute>()?.Order;
|
||||
Range = Find<CultInspectorRangeAttribute>();
|
||||
TextArea = Find<CultInspectorTextAreaAttribute>();
|
||||
AssetPath = Find<CultInspectorAssetPathAttribute>();
|
||||
}
|
||||
|
||||
public IReadOnlyList<Attribute> Attributes { get; }
|
||||
|
||||
// The declared label, or null; the lowering derives one from the member name.
|
||||
public string? Label { get; }
|
||||
public bool Hidden { get; }
|
||||
public bool ReadOnly { get; }
|
||||
public int? Order { get; }
|
||||
public CultInspectorRangeAttribute? Range { get; }
|
||||
public CultInspectorTextAreaAttribute? TextArea { get; }
|
||||
public CultInspectorAssetPathAttribute? AssetPath { get; }
|
||||
|
||||
public T? Find<T>() where T : Attribute => Attributes.OfType<T>().FirstOrDefault();
|
||||
}
|
||||
|
||||
public sealed class CultInspectorMember
|
||||
{
|
||||
internal CultInspectorMember(MemberInfo member, Type valueType, int slot, bool assignable, bool component, CultInspectorMetadata metadata)
|
||||
{
|
||||
Member = member;
|
||||
ValueType = valueType;
|
||||
Slot = slot;
|
||||
IsAssignable = assignable;
|
||||
Metadata = metadata;
|
||||
IsReadOnly = metadata.ReadOnly || !assignable && !component;
|
||||
}
|
||||
|
||||
public MemberInfo Member { get; }
|
||||
public string Name => Member.Name;
|
||||
public Type ValueType { get; }
|
||||
public int Slot { get; }
|
||||
public CultInspectorMetadata Metadata { get; }
|
||||
|
||||
// SetValue works: MessagePack's own rule, the one the registry persists by (a public setter or non-readonly field, or
|
||||
// any setter or readonly field under AllowPrivate). A composite component is never assignable; its owner is composed.
|
||||
public bool IsAssignable { get; }
|
||||
|
||||
// The lowering offers no edit: declared [CultInspectorReadOnly], or a member that can be neither assigned nor composed.
|
||||
public bool IsReadOnly { get; }
|
||||
|
||||
public object? GetValue(object target) =>
|
||||
Member is FieldInfo field ? field.GetValue(target) : ((PropertyInfo)Member).GetValue(target);
|
||||
|
||||
public void SetValue(object target, object? value)
|
||||
{
|
||||
if (!IsAssignable)
|
||||
throw new InvalidOperationException($"{Member.DeclaringType?.Name}.{Name} is not assignable.");
|
||||
if (Member is FieldInfo field) field.SetValue(target, value);
|
||||
else ((PropertyInfo)Member).SetValue(target, value);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class CultInspectorShape
|
||||
{
|
||||
internal CultInspectorShape(Type type, CultInspectorValueKind kind)
|
||||
{
|
||||
Type = type;
|
||||
Kind = kind;
|
||||
}
|
||||
|
||||
public Type Type { get; }
|
||||
public CultInspectorValueKind Kind { get; }
|
||||
|
||||
// List
|
||||
public Type? ElementType { get; internal set; }
|
||||
|
||||
// Dictionary
|
||||
public Type? KeyType { get; internal set; }
|
||||
public Type? ValueType { get; internal set; }
|
||||
|
||||
// RecordRef: the T of CultRecordRef<T>.
|
||||
public Type? RecordTarget { get; internal set; }
|
||||
|
||||
// Union: the declared [Union] subtypes in key order, and nothing else.
|
||||
public IReadOnlyList<Type> UnionChoices { get; internal set; } = Array.Empty<Type>();
|
||||
|
||||
// Nested: the members in inspection order. Composite: the components in constructor order.
|
||||
public IReadOnlyList<CultInspectorMember> Members { get; internal set; } = Array.Empty<CultInspectorMember>();
|
||||
|
||||
// Unsupported: why.
|
||||
public string? Reason { get; internal set; }
|
||||
|
||||
internal Type? BuildType { get; set; }
|
||||
internal ConstructorInfo? Composer { get; set; }
|
||||
}
|
||||
|
||||
public readonly struct CultInspectorClaim
|
||||
{
|
||||
internal CultInspectorClaim(Type? drawer, string? conflict)
|
||||
{
|
||||
Drawer = drawer;
|
||||
Conflict = conflict;
|
||||
}
|
||||
|
||||
// The claiming drawer class, or null for the lowering's built-in drawing.
|
||||
public Type? Drawer { get; }
|
||||
|
||||
// Set when the claim is contested; the lowering shows it instead of drawing.
|
||||
public string? Conflict { get; }
|
||||
}
|
||||
|
||||
// Which lowering drawer draws a value: a claim on one of the member's attributes, then a claim on the value's type
|
||||
// (exact, then its open generic definition), then none. A lowering builds this once from the drawer classes it
|
||||
// discovers; an invalid drawer or a contested claim is reported in Errors and draws nothing. An attribute claim
|
||||
// reaches every value drawn under the member, list elements and dictionary keys and values included, so the drawer
|
||||
// decides by the value type it is handed.
|
||||
public sealed class CultInspectorDrawerClaims
|
||||
{
|
||||
private readonly Dictionary<Type, Type> _claims = new Dictionary<Type, Type>();
|
||||
private readonly Dictionary<Type, string> _contested = new Dictionary<Type, string>();
|
||||
private readonly List<string> _errors = new List<string>();
|
||||
private readonly Dictionary<(Type, MemberInfo?), CultInspectorClaim> _resolved = new Dictionary<(Type, MemberInfo?), CultInspectorClaim>();
|
||||
|
||||
public CultInspectorDrawerClaims(IEnumerable<Type> drawerTypes, Type drawerContract)
|
||||
{
|
||||
if (drawerTypes == null) throw new ArgumentNullException(nameof(drawerTypes));
|
||||
if (drawerContract == null) throw new ArgumentNullException(nameof(drawerContract));
|
||||
foreach (var drawer in drawerTypes)
|
||||
{
|
||||
var attribute = drawer.GetCustomAttribute<CultInspectorDrawerAttribute>(false);
|
||||
if (attribute == null)
|
||||
continue;
|
||||
if (!drawerContract.IsAssignableFrom(drawer) || drawer.IsAbstract || drawer.GetConstructor(Type.EmptyTypes) == null)
|
||||
{
|
||||
_errors.Add($"{drawer.FullName} is marked [CultInspectorDrawer] but is not a concrete {drawerContract.Name} with a parameterless constructor.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var claimed = attribute.Claimed;
|
||||
if (_contested.TryGetValue(claimed, out var claimants))
|
||||
{
|
||||
_contested[claimed] = claimants + ", " + drawer.FullName;
|
||||
}
|
||||
else if (_claims.TryGetValue(claimed, out var holder))
|
||||
{
|
||||
_claims.Remove(claimed);
|
||||
_contested[claimed] = holder.FullName + ", " + drawer.FullName;
|
||||
}
|
||||
else
|
||||
{
|
||||
_claims[claimed] = drawer;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var pair in _contested.OrderBy(pair => pair.Key.FullName, StringComparer.Ordinal))
|
||||
_errors.Add($"{pair.Value} all claim {pair.Key.FullName}; none of them draws it.");
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> Errors => _errors;
|
||||
|
||||
public CultInspectorClaim Resolve(Type valueType, MemberInfo? member)
|
||||
{
|
||||
if (valueType == null) throw new ArgumentNullException(nameof(valueType));
|
||||
lock (_resolved)
|
||||
{
|
||||
if (!_resolved.TryGetValue((valueType, member), out var claim))
|
||||
_resolved[(valueType, member)] = claim = ResolveCore(valueType, member);
|
||||
return claim;
|
||||
}
|
||||
}
|
||||
|
||||
private CultInspectorClaim ResolveCore(Type valueType, MemberInfo? member)
|
||||
{
|
||||
Type? drawer = null;
|
||||
foreach (var attribute in member?.GetCustomAttributes(true).OfType<Attribute>() ?? Enumerable.Empty<Attribute>())
|
||||
{
|
||||
for (var type = attribute.GetType(); type != null && type != typeof(Attribute); type = type.BaseType)
|
||||
{
|
||||
if (_contested.TryGetValue(type, out var claimants))
|
||||
return new CultInspectorClaim(null, $"[{type.Name}] is claimed by {claimants}");
|
||||
if (!_claims.TryGetValue(type, out var claimant))
|
||||
continue;
|
||||
if (drawer != null && drawer != claimant)
|
||||
return new CultInspectorClaim(null, $"{member!.Name} carries attributes claimed by {drawer.FullName} and {claimant.FullName}");
|
||||
drawer = claimant;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (drawer != null)
|
||||
return new CultInspectorClaim(drawer, null);
|
||||
foreach (var type in valueType.IsGenericType ? new[] { valueType, valueType.GetGenericTypeDefinition() } : new[] { valueType })
|
||||
{
|
||||
if (_contested.TryGetValue(type, out var claimants))
|
||||
return new CultInspectorClaim(null, $"{type.FullName} is claimed by {claimants}");
|
||||
if (_claims.TryGetValue(type, out var claimant))
|
||||
return new CultInspectorClaim(claimant, null);
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
// A private copy of one stored document. A lowering draws into Document; the cached object is never touched.
|
||||
public sealed class CultInspectorEdit
|
||||
{
|
||||
internal CultInspectorEdit(CultStoredDocument source, object document)
|
||||
{
|
||||
Source = source;
|
||||
Document = document;
|
||||
}
|
||||
|
||||
public CultStoredDocument Source { get; }
|
||||
public object Document { get; }
|
||||
public bool IsSpent { get; private set; }
|
||||
|
||||
public bool IsFor(CultStoredDocument record) => !IsSpent && ReferenceEquals(Source, record);
|
||||
|
||||
// Upserts the copy under the source key. Committed, the cache holds the copy; refused, the cached object is
|
||||
// unchanged. Either way the edit is spent and the copy belongs to nobody: begin a new edit from the cache.
|
||||
public bool Commit(CultCache cache, out string? error)
|
||||
{
|
||||
if (cache == null) throw new ArgumentNullException(nameof(cache));
|
||||
if (IsSpent) throw new InvalidOperationException("This edit was already committed; begin a new edit.");
|
||||
IsSpent = true;
|
||||
try
|
||||
{
|
||||
cache.UpsertAsync(Source.Descriptor.DocumentType, Document, Source.Key).GetAwaiter().GetResult();
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
error = exception.GetBaseException().Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class CultInspectorModel
|
||||
{
|
||||
private const string RecordRefIdentity = "ref:";
|
||||
private const BindingFlags Public = BindingFlags.Public | BindingFlags.Instance;
|
||||
|
||||
private static readonly HashSet<Type> IntegerTypes = new HashSet<Type>
|
||||
{
|
||||
typeof(int), typeof(long), typeof(uint), typeof(short), typeof(ushort), typeof(byte), typeof(sbyte)
|
||||
};
|
||||
|
||||
private readonly Func<object, Type, byte[]> _serialize;
|
||||
private readonly Func<Type, byte[], object> _deserialize;
|
||||
private readonly Dictionary<Type, CultInspectorShape> _shapes = new Dictionary<Type, CultInspectorShape>();
|
||||
private readonly Dictionary<MemberInfo, CultInspectorMetadata> _metadata = new Dictionary<MemberInfo, CultInspectorMetadata>();
|
||||
|
||||
// serialize and deserialize are the store's document codec (CultDocumentMessagePackSerialization for .cc stores);
|
||||
// edits clone through it and dictionary keys compare by it.
|
||||
public CultInspectorModel(CultDocumentRegistry registry, Func<object, Type, byte[]> serialize, Func<Type, byte[], object> deserialize)
|
||||
{
|
||||
Registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_serialize = serialize ?? throw new ArgumentNullException(nameof(serialize));
|
||||
_deserialize = deserialize ?? throw new ArgumentNullException(nameof(deserialize));
|
||||
}
|
||||
|
||||
public CultDocumentRegistry Registry { get; }
|
||||
|
||||
public static string RecordLabel(CultStoredDocument record)
|
||||
{
|
||||
var name = record.Descriptor.NameAccessor?.Invoke(record.Document);
|
||||
return string.IsNullOrWhiteSpace(name) ? record.Key.Value : name!;
|
||||
}
|
||||
|
||||
// The key a CultRecordRef<T> holds; null and "" are both the empty key.
|
||||
public static string RecordKey(object? recordRef) => (recordRef as ICultRecordRef)?.Key.Value ?? string.Empty;
|
||||
|
||||
public CultInspectorShape ShapeOf(Type type)
|
||||
{
|
||||
if (type == null) throw new ArgumentNullException(nameof(type));
|
||||
lock (_shapes)
|
||||
{
|
||||
if (!_shapes.TryGetValue(type, out var shape))
|
||||
_shapes[type] = shape = Classify(type);
|
||||
return shape;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<CultInspectorMember> MembersOf(Type type)
|
||||
{
|
||||
var shape = ShapeOf(type);
|
||||
return shape.Kind == CultInspectorValueKind.Nested ? shape.Members : Array.Empty<CultInspectorMember>();
|
||||
}
|
||||
|
||||
public CultInspectorMetadata MetadataOf(MemberInfo? member)
|
||||
{
|
||||
if (member == null)
|
||||
return CultInspectorMetadata.None;
|
||||
lock (_metadata)
|
||||
{
|
||||
if (!_metadata.TryGetValue(member, out var metadata))
|
||||
_metadata[member] = metadata = new CultInspectorMetadata(member);
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
|
||||
public object Clone(object document, Type type) => _deserialize(type, _serialize(document, type));
|
||||
|
||||
public CultInspectorEdit BeginEdit(CultStoredDocument record)
|
||||
{
|
||||
if (record == null) throw new ArgumentNullException(nameof(record));
|
||||
return new CultInspectorEdit(record, Clone(record.Document, record.Descriptor.DocumentType));
|
||||
}
|
||||
|
||||
public object? CreateDefault(Type type)
|
||||
{
|
||||
if (type == typeof(string)) return string.Empty;
|
||||
if (type.IsValueType) return Activator.CreateInstance(type);
|
||||
var shape = ShapeOf(type);
|
||||
if (shape.Kind == CultInspectorValueKind.List && type.IsArray) return Array.CreateInstance(shape.ElementType!, 0);
|
||||
if (shape.Kind == CultInspectorValueKind.Dictionary) return Activator.CreateInstance(shape.BuildType!);
|
||||
return type.IsAbstract || type.IsInterface || type.GetConstructor(Type.EmptyTypes) == null ? null : Activator.CreateInstance(type);
|
||||
}
|
||||
|
||||
public object CreateUnionValue(Type unionType, Type subtype)
|
||||
{
|
||||
if (!ShapeOf(unionType).UnionChoices.Contains(subtype))
|
||||
throw new InvalidOperationException($"{subtype.Name} is not a declared [Union] subtype of {unionType.Name}.");
|
||||
if (subtype.GetConstructor(Type.EmptyTypes) == null)
|
||||
throw new InvalidOperationException($"{subtype.Name} has no parameterless constructor.");
|
||||
return Activator.CreateInstance(subtype)!;
|
||||
}
|
||||
|
||||
public object Compose(CultInspectorShape composite, IReadOnlyList<object?> components)
|
||||
{
|
||||
if (composite?.Composer == null)
|
||||
throw new ArgumentException("Only a composite shape is composed.", nameof(composite));
|
||||
return composite.Composer.Invoke(components.ToArray());
|
||||
}
|
||||
|
||||
// A new collection holding items; the value it replaces is never mutated.
|
||||
public object BuildList(Type listType, IReadOnlyList<object?> items)
|
||||
{
|
||||
var shape = ShapeOf(listType);
|
||||
if (shape.Kind != CultInspectorValueKind.List)
|
||||
throw new ArgumentException($"{listType.Name} is not a list the inspector builds.", nameof(listType));
|
||||
if (listType.IsArray)
|
||||
{
|
||||
var array = Array.CreateInstance(shape.ElementType!, items.Count);
|
||||
for (var i = 0; i < items.Count; i++) array.SetValue(items[i], i);
|
||||
return array;
|
||||
}
|
||||
|
||||
var list = (IList)Activator.CreateInstance(listType)!;
|
||||
foreach (var item in items) list.Add(item);
|
||||
return list;
|
||||
}
|
||||
|
||||
public object BuildDictionary(Type dictionaryType, IEnumerable<KeyValuePair<object?, object?>> entries)
|
||||
{
|
||||
var shape = ShapeOf(dictionaryType);
|
||||
if (shape.Kind != CultInspectorValueKind.Dictionary)
|
||||
throw new ArgumentException($"{dictionaryType.Name} is not a dictionary the inspector builds.", nameof(dictionaryType));
|
||||
var dictionary = (IDictionary)Activator.CreateInstance(shape.BuildType!)!;
|
||||
foreach (var entry in entries) dictionary.Add(entry.Key!, entry.Value);
|
||||
return dictionary;
|
||||
}
|
||||
|
||||
// Keys are the same key when they serialize the same. A CultRecordRef<T> key is its key string, null and "" alike.
|
||||
public string? KeyIdentity(object? key)
|
||||
{
|
||||
if (key == null) return null;
|
||||
if (key is ICultRecordRef reference) return RecordRefIdentity + reference.Key.Value;
|
||||
try
|
||||
{
|
||||
return "value:" + Convert.ToBase64String(_serialize(key, key.GetType()));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return key.GetType().FullName + ":" + key;
|
||||
}
|
||||
}
|
||||
|
||||
// Why candidate may not replace current among otherKeys, or null when it may (an unchanged key always may).
|
||||
public string? RefuseKey(object? candidate, object? current, IEnumerable<object?> otherKeys)
|
||||
{
|
||||
var identity = KeyIdentity(candidate);
|
||||
if (identity == KeyIdentity(current)) return null;
|
||||
if (identity == null) return "a null key";
|
||||
if (identity == RecordRefIdentity) return "an empty record reference as a key";
|
||||
return otherKeys.Any(key => KeyIdentity(key) == identity) ? "a duplicate key" : null;
|
||||
}
|
||||
|
||||
// A key to add: a record-reference key takes the first candidate record not already used; any other key is the
|
||||
// type's default. Null when that key is taken or cannot be made.
|
||||
public object? FreshKey(Type keyType, IEnumerable<object?> existingKeys, IEnumerable<CultStoredDocument> records)
|
||||
{
|
||||
var used = new HashSet<string?>(existingKeys.Select(KeyIdentity));
|
||||
if (ShapeOf(keyType).Kind == CultInspectorValueKind.RecordRef)
|
||||
{
|
||||
var record = RecordCandidates(keyType, records).FirstOrDefault(candidate => !used.Contains(RecordRefIdentity + candidate.Key.Value));
|
||||
return record == null ? null : CreateRecordRef(keyType, record.Key.Value);
|
||||
}
|
||||
|
||||
var fresh = CreateDefault(keyType);
|
||||
return fresh == null || used.Contains(KeyIdentity(fresh)) ? null : fresh;
|
||||
}
|
||||
|
||||
// The records a CultRecordRef<T> may point at: every record whose document is a T, by label.
|
||||
public IReadOnlyList<CultStoredDocument> RecordCandidates(Type recordRefType, IEnumerable<CultStoredDocument> records)
|
||||
{
|
||||
var target = ShapeOf(recordRefType).RecordTarget
|
||||
?? throw new ArgumentException($"{recordRefType.Name} is not a CultRecordRef<T>.", nameof(recordRefType));
|
||||
return records
|
||||
.Where(record => target.IsInstanceOfType(record.Document) && record.Key.Value.Length > 0)
|
||||
.OrderBy(RecordLabel, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public object CreateRecordRef(Type recordRefType, string key)
|
||||
{
|
||||
if (ShapeOf(recordRefType).Kind != CultInspectorValueKind.RecordRef)
|
||||
throw new ArgumentException($"{recordRefType.Name} is not a CultRecordRef<T>.", nameof(recordRefType));
|
||||
return Activator.CreateInstance(recordRefType, new CultRecordKey(key))!;
|
||||
}
|
||||
|
||||
private CultInspectorShape Classify(Type type)
|
||||
{
|
||||
if (type == typeof(string)) return new CultInspectorShape(type, CultInspectorValueKind.String);
|
||||
if (type == typeof(bool)) return new CultInspectorShape(type, CultInspectorValueKind.Bool);
|
||||
if (type.IsEnum) return new CultInspectorShape(type, CultInspectorValueKind.Enum);
|
||||
if (IntegerTypes.Contains(type)) return new CultInspectorShape(type, CultInspectorValueKind.Integer);
|
||||
if (type == typeof(float) || type == typeof(double)) return new CultInspectorShape(type, CultInspectorValueKind.Float);
|
||||
if (type.IsArray && type.GetArrayRank() != 1)
|
||||
return Unsupported(type, $"{type.Name} is a multi-dimensional array, which the inspector does not edit.");
|
||||
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(CultRecordRef<>))
|
||||
return new CultInspectorShape(type, CultInspectorValueKind.RecordRef) { RecordTarget = type.GetGenericArguments()[0] };
|
||||
|
||||
var dictionary = type.GetInterfaces().Prepend(type)
|
||||
.FirstOrDefault(candidate => candidate.IsGenericType && candidate.GetGenericTypeDefinition() == typeof(IDictionary<,>));
|
||||
if (dictionary != null)
|
||||
{
|
||||
var arguments = dictionary.GetGenericArguments();
|
||||
var build = type.IsInterface || type.IsAbstract ? typeof(Dictionary<,>).MakeGenericType(arguments) : type;
|
||||
return typeof(IDictionary).IsAssignableFrom(build) && build.GetConstructor(Type.EmptyTypes) != null
|
||||
? new CultInspectorShape(type, CultInspectorValueKind.Dictionary) { KeyType = arguments[0], ValueType = arguments[1], BuildType = build }
|
||||
: Unsupported(type, $"{type.Name} is a dictionary the inspector cannot rebuild.");
|
||||
}
|
||||
|
||||
if (type.IsArray)
|
||||
return new CultInspectorShape(type, CultInspectorValueKind.List) { ElementType = type.GetElementType() };
|
||||
if (type.IsGenericType && !type.IsAbstract && !type.IsInterface && typeof(IList).IsAssignableFrom(type) && type.GetConstructor(Type.EmptyTypes) != null)
|
||||
return new CultInspectorShape(type, CultInspectorValueKind.List) { ElementType = type.GetGenericArguments()[0] };
|
||||
|
||||
if (type.IsAbstract || type.IsInterface)
|
||||
{
|
||||
var choices = type.GetCustomAttributes<UnionAttribute>(false).OrderBy(union => union.Key).Select(union => union.SubType).ToArray();
|
||||
return choices.Length == 0
|
||||
? Unsupported(type, $"{type.Name} declares no [Union] subtypes.")
|
||||
: new CultInspectorShape(type, CultInspectorValueKind.Union) { UnionChoices = choices };
|
||||
}
|
||||
|
||||
var keyed = KeyedMembers(type);
|
||||
if (keyed.Length > 0)
|
||||
return new CultInspectorShape(type, CultInspectorValueKind.Nested) { Members = keyed };
|
||||
if (type.IsValueType)
|
||||
{
|
||||
var composite = Composite(type);
|
||||
if (composite != null)
|
||||
return composite;
|
||||
var fields = type.GetFields(Public).Where(field => !field.IsLiteral)
|
||||
.OrderBy(field => field.MetadataToken)
|
||||
.Select((field, slot) => new CultInspectorMember(field, field.FieldType, slot, !field.IsInitOnly, false, MetadataOf(field)))
|
||||
.ToArray();
|
||||
if (fields.Length > 0)
|
||||
return new CultInspectorShape(type, CultInspectorValueKind.Nested) { Members = fields };
|
||||
}
|
||||
|
||||
return Unsupported(type, "no drawer for " + type.FullName);
|
||||
}
|
||||
|
||||
// Documents take their members and slots from the registry's catalog; other MessagePack objects from their [Key]s.
|
||||
private CultInspectorMember[] KeyedMembers(Type type)
|
||||
{
|
||||
var allowPrivate = type.GetCustomAttribute<MessagePackObjectAttribute>(true)?.AllowPrivate == true;
|
||||
var candidates = type.GetMembers(Public)
|
||||
.Where(member => member is FieldInfo field && !field.IsLiteral ||
|
||||
member is PropertyInfo property && property.GetGetMethod() != null && property.GetIndexParameters().Length == 0)
|
||||
.Where(member => !member.IsDefined(typeof(IgnoreMemberAttribute), true));
|
||||
IEnumerable<(MemberInfo Member, int Slot)> slotted;
|
||||
if (type.IsDefined(typeof(CultDocumentAttribute), false))
|
||||
{
|
||||
var slots = Registry.GetRequired(type).ToCatalogEntry().Members.ToDictionary(member => member.MemberName, member => member.Slot, StringComparer.Ordinal);
|
||||
slotted = candidates.Where(member => slots.ContainsKey(member.Name)).Select(member => (member, slots[member.Name]));
|
||||
}
|
||||
else
|
||||
{
|
||||
slotted = candidates
|
||||
.Select(member => (Member: member, Slot: member.GetCustomAttribute<KeyAttribute>(true)?.IntKey))
|
||||
.Where(entry => entry.Slot != null)
|
||||
.Select(entry => (entry.Member, entry.Slot!.Value));
|
||||
}
|
||||
|
||||
return slotted
|
||||
.Select(entry => new CultInspectorMember(entry.Member, TypeOf(entry.Member), entry.Slot, Assignable(entry.Member, allowPrivate), false, MetadataOf(entry.Member)))
|
||||
.OrderBy(member => member.Metadata.Order ?? member.Slot)
|
||||
.ThenBy(member => member.Slot)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
// The longest public constructor whose every parameter takes a readable member of the same name and type. Public
|
||||
// fields it does not take are state a rebuild would drop, so such a type is not composite.
|
||||
private CultInspectorShape? Composite(Type type)
|
||||
{
|
||||
var readable = type.GetFields(Public).Where(field => !field.IsLiteral).Cast<MemberInfo>()
|
||||
.Concat(type.GetProperties(Public).Where(property => property.GetGetMethod() != null && property.GetIndexParameters().Length == 0))
|
||||
.ToArray();
|
||||
foreach (var constructor in type.GetConstructors().OrderByDescending(constructor => constructor.GetParameters().Length))
|
||||
{
|
||||
var parameters = constructor.GetParameters();
|
||||
if (parameters.Length == 0)
|
||||
break;
|
||||
var components = parameters
|
||||
.Select(parameter => readable.FirstOrDefault(member =>
|
||||
string.Equals(member.Name, parameter.Name, StringComparison.OrdinalIgnoreCase) && TypeOf(member) == parameter.ParameterType))
|
||||
.ToArray();
|
||||
if (components.Any(component => component == null) || components.Distinct().Count() != components.Length)
|
||||
continue;
|
||||
if (readable.OfType<FieldInfo>().Any(field => !components.Contains(field)))
|
||||
continue;
|
||||
return new CultInspectorShape(type, CultInspectorValueKind.Composite)
|
||||
{
|
||||
Members = components.Select((component, slot) => new CultInspectorMember(component!, TypeOf(component!), slot, false, true, MetadataOf(component))).ToArray(),
|
||||
Composer = constructor
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static CultInspectorShape Unsupported(Type type, string reason) =>
|
||||
new CultInspectorShape(type, CultInspectorValueKind.Unsupported) { Reason = reason };
|
||||
|
||||
private static Type TypeOf(MemberInfo member) => member is FieldInfo field ? field.FieldType : ((PropertyInfo)member).PropertyType;
|
||||
|
||||
private static bool Assignable(MemberInfo member, bool allowPrivate)
|
||||
{
|
||||
if (member is FieldInfo field)
|
||||
return !field.IsInitOnly || allowPrivate;
|
||||
var setter = ((PropertyInfo)member).GetSetMethod(true);
|
||||
return setter != null && (setter.IsPublic || allowPrivate);
|
||||
}
|
||||
}
|
||||
}
|
||||
292
tests/GameCult.Caching.Tests/CultInspectorModelTests.cs
Normal file
292
tests/GameCult.Caching.Tests/CultInspectorModelTests.cs
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using MessagePack;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GameCult.Caching.Tests
|
||||
{
|
||||
public class CultInspectorModelTests
|
||||
{
|
||||
private static readonly CultDocumentRegistry Registry =
|
||||
CultDocumentRegistry.ForTypes(new[] { typeof(InspectItem), typeof(InspectOther), typeof(InspectFixed) });
|
||||
|
||||
private static CultInspectorModel Model() => new CultInspectorModel(
|
||||
Registry,
|
||||
(value, type) => CultDocumentMessagePackSerialization.SerializeUntyped(value, type, Registry),
|
||||
(type, bytes) => CultDocumentMessagePackSerialization.DeserializeUntyped(type, bytes, Registry));
|
||||
|
||||
private static CultRecordRef<InspectItem> Ref(string key) => new CultRecordRef<InspectItem>(new CultRecordKey(key));
|
||||
|
||||
[Test]
|
||||
public void MembersFollowTheCatalogWithTheirMetadata()
|
||||
{
|
||||
var members = Model().MembersOf(typeof(InspectItem));
|
||||
|
||||
Assert.That(members.Select(member => member.Name),
|
||||
Is.EqualTo(new[] { "Value", "Name", "Secret", "Locked", "Shape", "Links", "Numbers" }), "order first, then slot");
|
||||
Assert.That(members.Single(member => member.Name == "Name").Metadata.Label, Is.EqualTo("Display Name"));
|
||||
Assert.That(members.Single(member => member.Name == "Name").IsReadOnly, Is.False);
|
||||
Assert.That(members.Single(member => member.Name == "Value").Metadata.Range!.Max, Is.EqualTo(10f));
|
||||
Assert.That(members.Single(member => member.Name == "Secret").Metadata.Hidden, Is.True);
|
||||
Assert.That(members.Single(member => member.Name == "Locked").IsReadOnly, Is.True);
|
||||
|
||||
var id = Model().MembersOf(typeof(InspectFixed)).Single();
|
||||
Assert.That(id.IsAssignable, Is.False, "a get-only member its constructor fills cannot be assigned");
|
||||
Assert.That(id.IsReadOnly, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClaimsResolveAttributeThenTypeThenBuiltIn()
|
||||
{
|
||||
var claims = new CultInspectorDrawerClaims(
|
||||
new[] { typeof(MarkDrawer), typeof(IntDrawer), typeof(ListDrawer), typeof(StringDrawerA), typeof(StringDrawerB), typeof(NotADrawer) },
|
||||
typeof(IFakeDrawer));
|
||||
FieldInfo Field(string name) => typeof(ClaimHost).GetField(name)!;
|
||||
|
||||
Assert.That(claims.Resolve(typeof(int), Field(nameof(ClaimHost.Marked))).Drawer, Is.EqualTo(typeof(MarkDrawer)));
|
||||
Assert.That(claims.Resolve(typeof(int), Field(nameof(ClaimHost.Plain))).Drawer, Is.EqualTo(typeof(IntDrawer)),
|
||||
"an invalid drawer claiming int does not contest the valid one");
|
||||
Assert.That(claims.Resolve(typeof(List<float>), null).Drawer, Is.EqualTo(typeof(ListDrawer)));
|
||||
Assert.That(claims.Resolve(typeof(float), null).Drawer, Is.Null);
|
||||
Assert.That(claims.Resolve(typeof(float), null).Conflict, Is.Null);
|
||||
|
||||
var contested = claims.Resolve(typeof(string), null);
|
||||
Assert.That(contested.Drawer, Is.Null);
|
||||
Assert.That(contested.Conflict, Does.Contain(nameof(StringDrawerA)).And.Contain(nameof(StringDrawerB)));
|
||||
Assert.That(claims.Errors.Any(error => error.Contains(nameof(NotADrawer))), Is.True);
|
||||
Assert.That(claims.Errors.Any(error => error.Contains(nameof(StringDrawerA)) && error.Contains(nameof(StringDrawerB))), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void UnionChoicesAreTheDeclaredSubtypesOnly()
|
||||
{
|
||||
var model = Model();
|
||||
var shape = model.ShapeOf(typeof(InspectShape));
|
||||
|
||||
Assert.That(shape.Kind, Is.EqualTo(CultInspectorValueKind.Union));
|
||||
Assert.That(shape.UnionChoices, Is.EqualTo(new[] { typeof(InspectCircle) }));
|
||||
Assert.That(model.CreateUnionValue(typeof(InspectShape), typeof(InspectCircle)), Is.InstanceOf<InspectCircle>());
|
||||
Assert.That(() => model.CreateUnionValue(typeof(InspectShape), typeof(InspectSquare)), Throws.InvalidOperationException);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RecordRefCandidatesAreRecordsAssignableToTheTarget()
|
||||
{
|
||||
using var cache = new CultCache(Registry);
|
||||
var a = await cache.UpsertAsync(typeof(InspectItem), new InspectItem { Name = "a" });
|
||||
var b = await cache.UpsertAsync(typeof(InspectItem), new InspectItem { Name = "b" });
|
||||
await cache.UpsertAsync(typeof(InspectOther), new InspectOther { Name = "o" });
|
||||
|
||||
var candidates = Model().RecordCandidates(typeof(CultRecordRef<InspectItem>), cache.AllStoredDocuments);
|
||||
|
||||
Assert.That(candidates.Select(candidate => candidate.Key), Is.EqualTo(new[] { a, b }));
|
||||
Assert.That(CultInspectorModel.RecordKey(default(CultRecordRef<InspectItem>)), Is.EqualTo(string.Empty));
|
||||
Assert.That(CultInspectorModel.RecordKey(new CultRecordRef<InspectItem>(a)), Is.EqualTo(a.Value));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DictionaryKeysRefuseNullEmptyReferencesAndDuplicates()
|
||||
{
|
||||
var model = Model();
|
||||
var others = new object?[] { Ref("b") };
|
||||
|
||||
Assert.That(model.KeyIdentity(default(CultRecordRef<InspectItem>)), Is.EqualTo(model.KeyIdentity(Ref(""))));
|
||||
Assert.That(model.RefuseKey(Ref(""), Ref("a"), others), Does.Contain("empty"));
|
||||
Assert.That(model.RefuseKey(default(CultRecordRef<InspectItem>), Ref("a"), others), Does.Contain("empty"));
|
||||
Assert.That(model.RefuseKey(Ref("b"), Ref("a"), others), Does.Contain("duplicate"));
|
||||
Assert.That(model.RefuseKey(null, Ref("a"), others), Does.Contain("null"));
|
||||
Assert.That(model.RefuseKey(Ref("c"), Ref("a"), others), Is.Null);
|
||||
Assert.That(model.RefuseKey(Ref("a"), Ref("a"), others), Is.Null, "an unchanged key is never refused");
|
||||
Assert.That(model.RefuseKey("x", "y", new object?[] { "x" }), Does.Contain("duplicate"));
|
||||
|
||||
using var cache = new CultCache(Registry);
|
||||
var first = await cache.UpsertAsync(typeof(InspectItem), new InspectItem { Name = "first" });
|
||||
var second = await cache.UpsertAsync(typeof(InspectItem), new InspectItem { Name = "second" });
|
||||
var fresh = model.FreshKey(typeof(CultRecordRef<InspectItem>), new object?[] { new CultRecordRef<InspectItem>(first) }, cache.AllStoredDocuments);
|
||||
Assert.That(CultInspectorModel.RecordKey(fresh), Is.EqualTo(second.Value));
|
||||
Assert.That(model.FreshKey(typeof(CultRecordRef<InspectItem>),
|
||||
new object?[] { new CultRecordRef<InspectItem>(first), new CultRecordRef<InspectItem>(second) }, cache.AllStoredDocuments), Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RefusedCommitLeavesTheCachedDocumentUnchanged()
|
||||
{
|
||||
var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc");
|
||||
try
|
||||
{
|
||||
using (var seed = CultCacheMessagePack.Create(filePath, new CultCacheOpenOptions { Registry = Registry }))
|
||||
{
|
||||
await seed.UpsertAsync(typeof(InspectItem), new InspectItem { Name = "original", Numbers = { 1, 2 } });
|
||||
await seed.FlushAsync();
|
||||
}
|
||||
|
||||
var model = Model();
|
||||
using var readOnly = CultCacheMessagePack.Create(filePath, new CultCacheOpenOptions { Registry = Registry, ReadOnly = true });
|
||||
var record = readOnly.AllStoredDocuments.Single();
|
||||
var edit = model.BeginEdit(record);
|
||||
Assert.That(edit.Document, Is.Not.SameAs(record.Document));
|
||||
((InspectItem)edit.Document).Name = "changed";
|
||||
((InspectItem)edit.Document).Numbers.Add(3);
|
||||
|
||||
Assert.That(edit.Commit(readOnly, out var error), Is.False);
|
||||
Assert.That(error, Is.Not.Null.And.Not.Empty);
|
||||
Assert.That(readOnly.Get<InspectItem>(record.Key)!.Name, Is.EqualTo("original"));
|
||||
Assert.That(readOnly.Get<InspectItem>(record.Key)!.Numbers, Is.EqualTo(new[] { 1, 2 }));
|
||||
Assert.That(edit.IsFor(record), Is.False, "a commit spends the edit");
|
||||
|
||||
using var writable = new CultCache(Registry);
|
||||
var key = await writable.UpsertAsync(typeof(InspectItem), new InspectItem { Name = "original" });
|
||||
var stored = writable.AllStoredDocuments.Single();
|
||||
var accepted = model.BeginEdit(stored);
|
||||
((InspectItem)accepted.Document).Name = "changed";
|
||||
Assert.That(accepted.Commit(writable, out _), Is.True);
|
||||
Assert.That(writable.Get<InspectItem>(key)!.Name, Is.EqualTo("changed"));
|
||||
Assert.That(((InspectItem)stored.Document).Name, Is.EqualTo("original"), "the replaced object was never mutated");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(filePath)) File.Delete(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ShapesClassifyCollectionsCompositesAndMultiDimensionalArrays()
|
||||
{
|
||||
var model = Model();
|
||||
|
||||
Assert.That(model.ShapeOf(typeof(float[,])).Kind, Is.EqualTo(CultInspectorValueKind.Unsupported));
|
||||
Assert.That(model.ShapeOf(typeof(float[,])).Reason, Does.Contain("multi-dimensional"));
|
||||
Assert.That(model.ShapeOf(typeof(List<int>)).ElementType, Is.EqualTo(typeof(int)));
|
||||
Assert.That(model.BuildList(typeof(float[]), new object?[] { 1f, 2f }), Is.EqualTo(new[] { 1f, 2f }));
|
||||
|
||||
var pair = model.ShapeOf(typeof(InspectPair));
|
||||
Assert.That(pair.Kind, Is.EqualTo(CultInspectorValueKind.Composite));
|
||||
Assert.That(pair.Members.Select(member => member.Name), Is.EqualTo(new[] { "x", "y" }));
|
||||
Assert.That(model.Compose(pair, new object?[] { 1f, 2f }), Is.EqualTo(new InspectPair(1f, 2f)));
|
||||
|
||||
Assert.That(model.ShapeOf(typeof(IDictionary<string, int>)).Kind, Is.EqualTo(CultInspectorValueKind.Dictionary));
|
||||
Assert.That(model.BuildDictionary(typeof(IDictionary<string, int>), new[] { new KeyValuePair<object?, object?>("a", 1) }),
|
||||
Is.EqualTo(new Dictionary<string, int> { ["a"] = 1 }));
|
||||
}
|
||||
|
||||
[CultDocument("tests.inspect_item", "tests.inspect_item.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class InspectItem
|
||||
{
|
||||
[Key(0)] [CultName] [CultInspectorLabel("Display Name")]
|
||||
public string Name = string.Empty;
|
||||
|
||||
[Key(1)] [CultInspectorRange(0, 10)] [CultInspectorOrder(-1)]
|
||||
public int Value;
|
||||
|
||||
[Key(2)] [CultInspectorHidden]
|
||||
public string Secret = string.Empty;
|
||||
|
||||
[Key(3)] [CultInspectorReadOnly]
|
||||
public string Locked = string.Empty;
|
||||
|
||||
[Key(4)]
|
||||
public InspectShape? Shape;
|
||||
|
||||
[Key(5)]
|
||||
public Dictionary<CultRecordRef<InspectItem>, int> Links = new Dictionary<CultRecordRef<InspectItem>, int>();
|
||||
|
||||
[Key(6)]
|
||||
public List<int> Numbers = new List<int>();
|
||||
}
|
||||
|
||||
[CultDocument("tests.inspect_other", "tests.inspect_other.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class InspectOther
|
||||
{
|
||||
[Key(0)] [CultName]
|
||||
public string Name = string.Empty;
|
||||
}
|
||||
|
||||
[CultDocument("tests.inspect_fixed", "tests.inspect_fixed.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class InspectFixed
|
||||
{
|
||||
[SerializationConstructor]
|
||||
public InspectFixed(string id)
|
||||
{
|
||||
Id = id;
|
||||
}
|
||||
|
||||
[Key(0)]
|
||||
public string Id { get; }
|
||||
}
|
||||
|
||||
[Union(0, typeof(InspectCircle))]
|
||||
public abstract class InspectShape
|
||||
{
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class InspectCircle : InspectShape
|
||||
{
|
||||
[Key(0)]
|
||||
public float Radius;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class InspectSquare : InspectShape
|
||||
{
|
||||
[Key(0)]
|
||||
public float Side;
|
||||
}
|
||||
|
||||
public readonly record struct InspectPair(float x, float y);
|
||||
|
||||
public interface IFakeDrawer
|
||||
{
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Field)]
|
||||
public sealed class InspectMarkAttribute : Attribute
|
||||
{
|
||||
}
|
||||
|
||||
public sealed class ClaimHost
|
||||
{
|
||||
[InspectMark] public int Marked;
|
||||
public int Plain;
|
||||
}
|
||||
|
||||
[CultInspectorDrawer(typeof(InspectMarkAttribute))]
|
||||
public sealed class MarkDrawer : IFakeDrawer
|
||||
{
|
||||
}
|
||||
|
||||
[CultInspectorDrawer(typeof(int))]
|
||||
public sealed class IntDrawer : IFakeDrawer
|
||||
{
|
||||
}
|
||||
|
||||
[CultInspectorDrawer(typeof(List<>))]
|
||||
public sealed class ListDrawer : IFakeDrawer
|
||||
{
|
||||
}
|
||||
|
||||
[CultInspectorDrawer(typeof(string))]
|
||||
public sealed class StringDrawerA : IFakeDrawer
|
||||
{
|
||||
}
|
||||
|
||||
[CultInspectorDrawer(typeof(string))]
|
||||
public sealed class StringDrawerB : IFakeDrawer
|
||||
{
|
||||
}
|
||||
|
||||
[CultInspectorDrawer(typeof(int))]
|
||||
public sealed class NotADrawer
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue