1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
|
/***************************************************************************************************
Copyright (C) 2025 The Qt Company Ltd.
SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
***************************************************************************************************/
using System.Collections.Concurrent;
using System.Reflection;
namespace Test_Utils
{
using Qt.DotNet.Utils;
[TestClass]
public partial class Test_LazyFactory
{
// Verify that when one owner initializes the property with an initFunc, a second owner
// without an initFunc does not erroneously reuse the cached value.
[TestMethod]
public void SecondOwnerWithoutInitFunc_ShouldNotShareValue()
{
var factory = new LazyFactory();
var owner1 = new Dummy();
var owner2 = new Dummy();
var initCalls = 0;
// First owner initializes via initFunc
var value1 = factory.Get(() => owner1.Prop, () => Interlocked.Increment(ref initCalls));
// Second owner without initFunc should not pick up the same stored value
var value2 = factory.Get(() => owner2.Prop);
Assert.AreEqual(1, initCalls, "InitFunc should only have been called once.");
Assert.AreNotEqual(value1, value2, "Expected second owner to not share the initialized "
+ "value.");
}
// Ensure that concurrent 'Get' calls on the same owner and property only invoke the
// initializer once, preventing race conditions.
[TestMethod]
public void ConcurrentSameOwner_OnlyOneInitCall()
{
var factory = new LazyFactory();
var owner = new Dummy();
var callCount = 0;
var initFunc = () => Interlocked.Increment(ref callCount);
// Kick off two Gets in parallel
var t1 = Task.Run(() => factory.Get(() => owner.Prop, initFunc));
var t2 = Task.Run(() => factory.Get(() => owner.Prop, initFunc));
// Wait for both to finish
Task.WaitAll(t1, t2);
// Exactly one initializer invocation:
Assert.AreEqual(1, callCount);
// And both results are identical (i.e. the cached value):
Assert.AreEqual(t1.Result, t2.Result);
}
// Verify that when the initializer throws, each 'Get' call invokes the initializer
// again instead of caching the exception (no exception caching behavior).
// TODO: Maybe introduce exception caching using Lazy with possible enum param to switch
// behavior.
[TestMethod]
public void InitFuncThrows_EachCallInvokesInit()
{
var factory = new LazyFactory();
var owner = new Dummy();
var throwCount = 0;
Func<int> initFunc = () =>
{
_ = Interlocked.Increment(ref throwCount);
throw new InvalidOperationException("Init failed");
};
// First call should invoke initFunc and throw
_ = Assert.ThrowsExactly<InvalidOperationException>(() => factory.Get(() => owner.Prop,
initFunc));
Assert.AreEqual(1, throwCount, "Initializer should have been called once");
// Second call should invoke initFunc again and throw
_ = Assert.ThrowsExactly<InvalidOperationException>(() => factory.Get(() => owner.Prop,
initFunc));
Assert.AreEqual(2, throwCount, "Initializer should have been called on each Get");
}
// Memory risk:
// We keep every (owner, PropertyInfo) pair forever. If we call 'Get' on hundreds or
// thousands of different objects - or on many different properties - we'll accumulate
// entries indefinitely.
// TODO: Maybe use ConditionalWeakTable instead of ConcurrentDictionary. Another option
// is to implement a cleanup mechanism using a WeakKey and a cleanup GC collected
// owners method that runs periodically or on demand.
[TestMethod]
public void Cache_ShouldBeEmptyAfterOwnerGarbageCollection()
{
var factory = new LazyFactory();
var foo = new Dummy();
// Populate cache
_ = factory.Get(() => foo.Prop, () => 123);
// Reflect into private cache
var objectProperties = typeof(LazyFactory)
.GetProperty("ObjectsCache", BindingFlags.Instance | BindingFlags.NonPublic);
Assert.IsNotNull(objectProperties, "The objectProperties should not be null.");
var cache = objectProperties.GetValue(factory)
as ConcurrentDictionary<(object, PropertyInfo), object>;
Assert.IsNotNull(cache, "The cache should not be null.");
// Drop strong reference and force GC
foo = null;
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
Assert.Inconclusive("Cache cleanup not implemented; entries remain indefinitely.");
// Expect cache to clean up stale entries
Assert.IsEmpty(cache, "Expected cache to remove entries after owner is collected.");
}
// Behavioral quirk:
// initFunc == null returns default(T).
// If we call 'Get(() => Foo)' without an initializer, we'll always get default
// (e.g., 0 or null). The factory shall not cache the default value.
[TestMethod]
public void WithoutInitFunc_GetReturnsDefault_AndAllowsLaterInit()
{
var factory = new LazyFactory();
var foo = new Dummy();
// 1) First call with no initFunc returns default(T) (e.g. 0 or null)
Assert.AreEqual(0, factory.Get(() => foo.Prop));
// 2) Still no caching of default—still default on a second no-init call
Assert.AreEqual(0, factory.Get(() => foo.Prop));
// 3) Now supply an initFunc; it runs once and caches the result
Assert.AreEqual(42, factory.Get(() => foo.Prop, () => 42));
// 4) Subsequent calls without an initFunc now return the cached 42
Assert.AreEqual(42, factory.Get(() => foo.Prop));
}
// Behavioral quirk:
// LazyFactory implements INotifyPropertyChanged, but in 'Set' we're raising the factory's
// PropertyChanged event, passing the owner as the sender. Handlers wired on the owner's
// own PropertyChanged won't fire, and handlers on the factory will get a "wrong" sender.
[TestMethod]
public void ShouldRaisePropertyChangedOnOwner()
{
var factory = new LazyFactory();
var foo = new NotifyingDummy();
var eventRaised = false;
factory.PropertyChanged += (_, args) =>
{
eventRaised = true;
Assert.AreEqual(nameof(NotifyingDummy.Prop), args.PropertyName);
};
factory.Set(() => foo.Prop, 100);
Assert.AreEqual(100, factory.Get(() => foo.Prop));
Assert.IsTrue(eventRaised, "Setting lazy property should raise PropertyChanged on the "
+ "factory instance passing the owner.");
}
// Make sure we are always raising the factory's event regardless of owner type.
[TestMethod]
public void ShouldRaisePropertyChangedOnOwner_WithoutINotifyPropertyChanged()
{
var factory = new LazyFactory();
var eventRaised = false;
factory.PropertyChanged += (_, args) =>
{
eventRaised = true;
Assert.AreEqual(nameof(AppSettings.IsFeatureEnabled), args.PropertyName);
};
factory.Set(() => AppSettings.IsFeatureEnabled, true);
Assert.IsTrue(factory.Get(() => AppSettings.IsFeatureEnabled));
Assert.IsTrue(eventRaised);
}
}
}
|