Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
7777ed1
Use lazy-loading in `GetSourceCollectionAsync()`
JeremyCaney Jul 29, 2026
6e064a1
Added unit tests to confirm load order
JeremyCaney Jul 29, 2026
d4b4020
Ensure `EnsureLoaded()` is awaited
JeremyCaney Jul 29, 2026
03ed27c
`EnsureLoaded()` for `AsAttributeDictionary()`
JeremyCaney Jul 31, 2026
51d0f4e
Added unit test for `AsAttributeDictionary()` fix
JeremyCaney Jul 31, 2026
faf3639
Ensure view model can't be mapped by reflection
JeremyCaney Jul 31, 2026
b88d436
Ensure metadata lookup items are loaded on mapping
JeremyCaney Jul 31, 2026
f287c1d
Prevent duplicate mapping of properties
JeremyCaney Jul 31, 2026
ec102ab
Introduced view models to test expanded maps
JeremyCaney Aug 1, 2026
49a6048
Added unit tests to confirm properties mapped once
JeremyCaney Aug 1, 2026
0f51264
Made `AddMissingAssociations()` concurrent
JeremyCaney Aug 2, 2026
a971aeb
Applied `AddMissingAssociations()` to caller
JeremyCaney Aug 2, 2026
bc73f87
Update unit tests for `AddMissingAssociations()`
JeremyCaney Aug 2, 2026
b42673a
Introduce new `MapPath` class
JeremyCaney Aug 3, 2026
7a5bd2c
Added completion semantics to mapped cache entries
JeremyCaney Aug 4, 2026
61b1487
Added `MapPath` to `MapAsync()` chain
JeremyCaney Aug 4, 2026
5a6b8a9
Added `TryGetValue(…, includeInitializing)` param
JeremyCaney Aug 4, 2026
ee9a9a5
Wire-up `Complete()` in `Register()`
JeremyCaney Aug 4, 2026
b197889
Wire-up `Fault()` in `MapAsync()`
JeremyCaney Aug 4, 2026
1334a2b
Introduce `resolveCachedEntry()`
JeremyCaney Aug 4, 2026
28f0b07
Added unit tests for completion semantics
JeremyCaney Aug 4, 2026
a64b1f8
Added unit test for `MapPath.Contains()`
JeremyCaney Aug 4, 2026
dbb4798
Introduced `CircularConstructorTopicViewModel`
JeremyCaney Aug 4, 2026
dc88d41
Added unit test for positional constructor mapping
JeremyCaney Aug 4, 2026
4a67bab
Added unit test for circular constructor reference
JeremyCaney Aug 4, 2026
73e2f3a
Introduced models for concurrent mapping tests
JeremyCaney Aug 4, 2026
91d0a21
Added `CreateGatedMappingService()` helper
JeremyCaney Aug 4, 2026
678bf64
Added unit test for concurrent model construction
JeremyCaney Aug 4, 2026
6d59060
Added unit test for faulty model construction
JeremyCaney Aug 4, 2026
86c6f7e
Lock creation of new view model collections
JeremyCaney Aug 4, 2026
9dfc24c
Lock the addition of view models to collections
JeremyCaney Aug 4, 2026
a101412
Allow the lazy concurrency repository fault
JeremyCaney Aug 4, 2026
96eae01
Introduced models for collection concurrency tests
JeremyCaney Aug 4, 2026
f6a85a2
Introduced new `RendezvousTopicLazyLoader`
JeremyCaney Aug 4, 2026
3a50d6b
Added unit tests for concurrent collection mapping
JeremyCaney Aug 4, 2026
c5f191b
Rely on `WhenAll()` over `WhenAny()`
JeremyCaney Aug 4, 2026
3359853
Introduced `StaggeredTopicLazyLoader`
JeremyCaney Aug 4, 2026
1a631ab
Added unit test for collection mapping order
JeremyCaney Aug 4, 2026
487ecf2
Prefer direct `WhenAll(IEnumerable)`
JeremyCaney Aug 4, 2026
3a16836
Cover `Deferred` in `Topic.Relationships.Clear()`
JeremyCaney Aug 4, 2026
e308bc5
Added unit test to confirm `Clear()` fix
JeremyCaney Aug 4, 2026
7d5cad0
Added unit test to test context of `Clear()` fix
JeremyCaney Aug 4, 2026
c6e644d
Serialize `PopulateTargetCollectionAsync()`
JeremyCaney Aug 5, 2026
277edab
Introduced `StaggeredStubTopicRepository`
JeremyCaney Aug 5, 2026
909121d
Introduced `NestedReferenceAttribute` model
JeremyCaney Aug 5, 2026
23f4adf
Added unit test for topic mapping serialization
JeremyCaney Aug 5, 2026
f0c65ea
Ensure target topic is fully loaded
JeremyCaney Aug 5, 2026
59a8b85
Optionally allow tracking load counting
JeremyCaney Aug 5, 2026
f235508
Added unit test for topic mapping warmup
JeremyCaney Aug 5, 2026
4857676
Defer `GetContentTypeDescriptors()` to first use
JeremyCaney Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*==============================================================================================================================
| Author Ignia, LLC
| Client Ignia, LLC
| Project Topics Library
\=============================================================================================================================*/
using OnTopic.ViewModels.BindingModels;

namespace OnTopic.Tests.BindingModels;

/*==============================================================================================================================
| BINDING MODEL: NESTED REFERENCE ATTRIBUTE TOPIC
\-----------------------------------------------------------------------------------------------------------------------------*/
/// <summary>
/// Provides a minimal implementation of a custom topic binding model with both a scalar value and a reference property, for
/// use as an item within a <see cref="ContentTypeDescriptorTopicBindingModel.Attributes"/> collection.
/// </summary>
/// <remarks>
/// This is a sample class intended for test purposes only; it is not designed for use in a production environment.
/// </remarks>
public class NestedReferenceAttributeTopicBindingModel : AttributeDescriptorTopicBindingModel {

public NestedReferenceAttributeTopicBindingModel(string key) : base(key, "TextAttributeDescriptor") { }

public AssociatedTopicBindingModel? BaseTopic { get; set; }

} //Class
40 changes: 39 additions & 1 deletion OnTopic.Tests/ITopicLazyLoadableTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
| Client Ignia, LLC
| Project Topics Library
\=============================================================================================================================*/
using OnTopic.Associations;
using OnTopic.Repositories;
using OnTopic.Tests.TestDoubles;
using Xunit;
Expand All @@ -19,6 +20,11 @@ namespace OnTopic.Tests;
[ExcludeFromCodeCoverage]
public class ITopicLazyLoadableTest {

/*============================================================================================================================
| PROPERTY: CANCELLATION TOKEN
\---------------------------------------------------------------------------------------------------------------------------*/
private static CancellationToken CancellationToken => TestContext.Current.CancellationToken;

/*============================================================================================================================
| TEST: IS LOADED: NON-RECURSIVE: IGNORES UNLOADED CHILDREN
\---------------------------------------------------------------------------------------------------------------------------*/
Expand Down Expand Up @@ -161,7 +167,39 @@ public void IsLoaded_NotLoadedChildren_NeverTriggersLoad() {
[Fact]
public void EnsureLoaded_NullResolver_DoesNotThrow() {
var topic = new Topic("Topic", "Page");
((ITopicLazyLoadable)topic).EnsureLoaded(TopicPayload.All);
((ITopicLazyLoadable)topic).EnsureLoaded(TopicPayload.All, CancellationToken);
}

/*============================================================================================================================
| TEST: ENSURE LOADED: CLEARED RELATIONSHIP: DOES NOT RESURRECT
\---------------------------------------------------------------------------------------------------------------------------*/
/// <summary>
/// Registers a <see cref="Topic.Relationships"/> deferred entry, then calls <see cref=
/// "TopicRelationshipMultiMap.Clear(String)"/> on that key. Confirms that a subsequent <see cref=
/// "ITopicLazyLoadable.EnsureLoaded(TopicPayload,System.Threading.CancellationToken)"/> for <see cref=
/// "TopicPayload.Relationships"/> never reaches the <see cref="ITopicLazyLoader"/>; since <see cref=
/// "TopicRelationshipMultiMap.Clear(String)"/> already purged the deferred entry, there is nothing left to resolve, and the
/// previously cleared relationship isn't resurrected.
/// </summary>
[Fact]
public async Task EnsureLoaded_ClearedRelationship_DoesNotResurrect() {

var topic = new Topic("Test", "Page", null, 1);
var rawLoadable = (ITopicLazyLoadable)topic;
var rawTopic = (ITopicBackingAccessor)topic;
var loader = new TrackingTopicLazyLoader();

// Set up and clear via the backing accessor so this doesn't itself trigger a load once LoadState flips to NotLoaded; the
// loader is stamped afterward, ahead of the explicit EnsureLoaded() call below
rawTopic.Relationships.Deferred.SetValue("Related", 999);
rawTopic.Relationships.Clear("Related");
rawLoadable.Loader = loader;

await rawLoadable.EnsureLoaded(TopicPayload.Relationships, CancellationToken);

Assert.False(loader.WasCalled);
Assert.Empty(rawTopic.Relationships.GetValues("Related"));

}

/*============================================================================================================================
Expand Down
107 changes: 107 additions & 0 deletions OnTopic.Tests/ReverseTopicMappingServiceTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
using OnTopic.TestDoubles.Metadata;
using OnTopic.Tests.BindingModels;
using OnTopic.Tests.Fixtures;
using OnTopic.Tests.TestDoubles;
using Xunit;

namespace OnTopic.Tests;
Expand Down Expand Up @@ -325,6 +326,112 @@ public async Task Map_NestedTopics_ReturnsMappedTopic() {

}

/*============================================================================================================================
| TEST: MAP: NESTED TOPICS: STAGGERED COMPLETION: PRESERVES SOURCE ORDER
\---------------------------------------------------------------------------------------------------------------------------*/
/// <summary>
/// Establishes a <see cref="ReverseTopicMappingService"/> backed by a <see cref="StaggeredStubTopicRepository"/> whose
/// per-item topic reference lookups resolve out of call order: The first-declared item resolves slowest, the last-declared
/// item resolves instantly. Confirms nested topics still land in the binding model's source order, since <see cref=
/// "ReverseTopicMappingService"/> maps and adds each child sequentially rather than racing completions.
/// </summary>
[Fact]
public async Task Map_NestedTopics_StaggeredCompletion_PreservesSourceOrder() {

// Declared in call order; delays fall in reverse, so the first-added item resolves last
List<(string UniqueKey, TimeSpan Delay)> attributes = [
("Root:Configuration:ContentTypes:Attributes:Key", TimeSpan.FromMilliseconds(120)),
("Root:Configuration:ContentTypes:Attributes:ContentType", TimeSpan.FromMilliseconds(60)),
("Root:Configuration:ContentTypes:Attributes:Title", TimeSpan.Zero)
];

var delaysByKey = attributes.ToDictionary(attribute => attribute.UniqueKey, attribute => attribute.Delay);
var topicRepository = new StaggeredStubTopicRepository(delaysByKey);
var mappingService = new ReverseTopicMappingService(topicRepository);
var bindingModel = new ContentTypeDescriptorTopicBindingModel("Test");

for (var i = 0; i < attributes.Count; i++) {
bindingModel.Attributes.Add(
new NestedReferenceAttributeTopicBindingModel($"Attribute{i + 1}") {
BaseTopic = new() {
UniqueKey = attributes[i].UniqueKey
}
}
);
}

var topic = new ContentTypeDescriptor("Test", "ContentTypeDescriptor");
var target = (ContentTypeDescriptor?)await mappingService.MapAsync(bindingModel, topic);
var container = target?.Children.GetValue("Attributes");

Assert.NotNull(container);
Assert.Equal(
Enumerable.Range(1, attributes.Count).Select(i => $"Attribute{i}"),
container.Children.Select(child => child.Key)
);

}

/*============================================================================================================================
| TEST: MAP: SPARSE TOPIC: FILLS EXTENDED ATTRIBUTES ONCE
\---------------------------------------------------------------------------------------------------------------------------*/
/// <summary>
/// Maps a scalar-only binding model onto a target stamped with a <see cref="TrackingTopicLazyLoader"/> whose <see cref=
/// "Topic.Attributes"/> are <see cref="LoadState.NotLoaded"/>. Confirms <see cref="ReverseTopicMappingService"/> warms <see
/// cref="TopicPayload.ExtendedAttributes"/> exactly once at the start of the map, rather than leaving it to the attribute
/// collection's own synchronous autoload.
/// </summary>
[Fact]
public async Task Map_ScalarProperties_FillsExtendedAttributesOnce() {

var bindingModel = new TextAttributeTopicBindingModel("Test") {
ContentType = "TextAttributeDescriptor",
DefaultValue = "World"
};

var target = new TextAttributeDescriptor("Test", "TextAttributeDescriptor");
var loader = new TrackingTopicLazyLoader(markLoaded: true);

((ITopicLazyLoadable)target).Loader = loader;
target.Attributes.LoadState = LoadState.NotLoaded;

_ = await _mappingService.MapAsync(bindingModel, target);

Assert.Equal(1, loader.CallCount);
Assert.Equal(TopicPayload.ExtendedAttributes, loader.Payloads[0]);

}

/*============================================================================================================================
| TEST: MAP: NESTED TOPICS: FILLS CONTAINER CHILDREN
\---------------------------------------------------------------------------------------------------------------------------*/
/// <summary>
/// Maps a nested-topic binding model onto a target whose <c>Attributes</c> container is stamped with its own <see cref=
/// "TrackingTopicLazyLoader"/> and left <see cref="LoadState.NotLoaded"/>, even though the target's own <see cref=
/// "Topic.Children"/> are already loaded. Confirms <see cref="ReverseTopicMappingService"/> warms the container
/// independently before <c>PopulateTargetCollectionAsync</c> probes its existing children.
/// </summary>
[Fact]
public async Task Map_NestedTopics_FillsContainerChildren() {

var bindingModel = new ContentTypeDescriptorTopicBindingModel("Test");

bindingModel.Attributes.Add(new TextAttributeTopicBindingModel("Attribute1"));

var target = new ContentTypeDescriptor("Test", "ContentTypeDescriptor");
var container = new Topic("Attributes", "List", target);
var containerLoader = new TrackingTopicLazyLoader(markLoaded: true);

((ITopicLazyLoadable)container).Loader = containerLoader;
container.Children.LoadState = LoadState.NotLoaded;

_ = (ContentTypeDescriptor?)await _mappingService.MapAsync(bindingModel, target);

Assert.Equal(1, containerLoader.CallCount);
Assert.Equal(TopicPayload.Children, containerLoader.Payloads[0]);

}

/*============================================================================================================================
| TEST: MAP: TOPIC REFERENCES: RETURNS MAPPED TOPIC
\---------------------------------------------------------------------------------------------------------------------------*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@ internal sealed class BlockingStubLazyLoadingTopicRepository: StubLazyLoadingTop
/// </summary>
public void ReleaseEnsureLoadedGate() => _ensureLoadedGate?.SetResult();

/*============================================================================================================================
| METHOD: FAULT ENSURE LOADED GATE
\---------------------------------------------------------------------------------------------------------------------------*/
/// <summary>
/// Faults a suspended <see cref="EnsureLoaded"/> call "armed" via <see cref="ArmEnsureLoadedGate"/> with the supplied
/// <paramref name="exception"/>, so a test can simulate a lazy load that throws while a second pass awaits the same entry.
/// </summary>
/// <param name="exception">The exception to surface from the suspended <see cref="EnsureLoaded"/> call.</param>
public void FaultEnsureLoadedGate(Exception exception) => _ensureLoadedGate?.SetException(exception);

/*============================================================================================================================
| METHOD: LOAD
\---------------------------------------------------------------------------------------------------------------------------*/
Expand Down
7 changes: 7 additions & 0 deletions OnTopic.Tests/TestDoubles/FakeViewModelLookupService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,18 @@ public FakeViewModelLookupService() {
Add(typeof(AmbiguousRelationTopicViewModel));
Add(typeof(AscendentSpecializedTopicViewModel));
Add(typeof(AscendentTopicViewModel));
Add(typeof(CircularConstructorTopicViewModel));
Add(typeof(CircularTopicViewModel));
Add(typeof(ConcurrentExpansionRootTopicViewModel));
Add(typeof(ConcurrentExpansionSharedTopicViewModel));
Add(typeof(ConcurrentReferenceTopicViewModel));
Add(typeof(ConstructedTopicViewModel));
Add(typeof(DefaultValueTopicViewModel));
Add(typeof(DescendentSpecializedTopicViewModel));
Add(typeof(DescendentTopicViewModel));
Add(typeof(DisableMappingTopicViewModel));
Add(typeof(ExpansionParentTopicViewModel));
Add(typeof(ExpansionSharedTopicViewModel));
Add(typeof(FallbackViewModel));
Add(typeof(FilteredTopicViewModel));
Add(typeof(FlattenChildrenTopicViewModel));
Expand All @@ -58,6 +64,7 @@ public FakeViewModelLookupService() {
Add(typeof(RelationWithChildrenTopicViewModel));
Add(typeof(RequiredObjectTopicViewModel));
Add(typeof(RequiredTopicViewModel));
Add(typeof(SharedConcurrentTopicViewModel));
Add(typeof(TopicReferenceAttributeDescriptorTopicViewModel));
Add(typeof(TopicReferenceTopicViewModel));

Expand Down
Loading