Skip to content

Commit 2c05207

Browse files
committed
Add unit tests for optimistic updates, store history, and tab synchronization
- Implement `OptimisticUpdateTests` to validate optimistic update behavior in the store. - Create `StoreHistoryTests` to ensure correct functionality of history tracking and undo/redo operations. - Introduce `TabSyncOptionsTests` to verify tab synchronization options and their behavior. - Add `StoreGeneratorTests` to test the code generation for store records and actions. - Update project references and include necessary packages for testing.
1 parent 553877c commit 2c05207

25 files changed

Lines changed: 3932 additions & 16 deletions

EasyAppDev.Blazor.Store.sln

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20
1515
EndProject
1616
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EasyAppDev.Blazor.Store.Sample", "samples\EasyAppDev.Blazor.Store.Sample\EasyAppDev.Blazor.Store.Sample.csproj", "{00674D7C-34EB-409B-A32E-5AF7DFA50D37}"
1717
EndProject
18+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EasyAppDev.Blazor.Store.Generators", "src\EasyAppDev.Blazor.Store.Generators\EasyAppDev.Blazor.Store.Generators.csproj", "{BA9CC879-8F10-4F1E-9AC1-9E81EED42459}"
19+
EndProject
1820
Global
1921
GlobalSection(SolutionConfigurationPlatforms) = preSolution
2022
Debug|Any CPU = Debug|Any CPU
@@ -61,6 +63,18 @@ Global
6163
{00674D7C-34EB-409B-A32E-5AF7DFA50D37}.Release|x64.Build.0 = Release|Any CPU
6264
{00674D7C-34EB-409B-A32E-5AF7DFA50D37}.Release|x86.ActiveCfg = Release|Any CPU
6365
{00674D7C-34EB-409B-A32E-5AF7DFA50D37}.Release|x86.Build.0 = Release|Any CPU
66+
{BA9CC879-8F10-4F1E-9AC1-9E81EED42459}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
67+
{BA9CC879-8F10-4F1E-9AC1-9E81EED42459}.Debug|Any CPU.Build.0 = Debug|Any CPU
68+
{BA9CC879-8F10-4F1E-9AC1-9E81EED42459}.Debug|x64.ActiveCfg = Debug|Any CPU
69+
{BA9CC879-8F10-4F1E-9AC1-9E81EED42459}.Debug|x64.Build.0 = Debug|Any CPU
70+
{BA9CC879-8F10-4F1E-9AC1-9E81EED42459}.Debug|x86.ActiveCfg = Debug|Any CPU
71+
{BA9CC879-8F10-4F1E-9AC1-9E81EED42459}.Debug|x86.Build.0 = Debug|Any CPU
72+
{BA9CC879-8F10-4F1E-9AC1-9E81EED42459}.Release|Any CPU.ActiveCfg = Release|Any CPU
73+
{BA9CC879-8F10-4F1E-9AC1-9E81EED42459}.Release|Any CPU.Build.0 = Release|Any CPU
74+
{BA9CC879-8F10-4F1E-9AC1-9E81EED42459}.Release|x64.ActiveCfg = Release|Any CPU
75+
{BA9CC879-8F10-4F1E-9AC1-9E81EED42459}.Release|x64.Build.0 = Release|Any CPU
76+
{BA9CC879-8F10-4F1E-9AC1-9E81EED42459}.Release|x86.ActiveCfg = Release|Any CPU
77+
{BA9CC879-8F10-4F1E-9AC1-9E81EED42459}.Release|x86.Build.0 = Release|Any CPU
6478
EndGlobalSection
6579
GlobalSection(SolutionProperties) = preSolution
6680
HideSolutionNode = FALSE
@@ -69,5 +83,6 @@ Global
6983
{E9082EA4-D24F-4FE1-BE1E-4A06A0F0355E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
7084
{A06FE2FC-C002-4BAC-AFFA-5ABE8595CF78} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
7185
{00674D7C-34EB-409B-A32E-5AF7DFA50D37} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
86+
{BA9CC879-8F10-4F1E-9AC1-9E81EED42459} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
7287
EndGlobalSection
7388
EndGlobal
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>netstandard2.0</TargetFramework>
5+
<LangVersion>latest</LangVersion>
6+
<Nullable>enable</Nullable>
7+
<ImplicitUsings>enable</ImplicitUsings>
8+
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
9+
10+
<!-- NuGet Package Properties -->
11+
<PackageId>EasyAppDev.Blazor.Store.Generators</PackageId>
12+
<Version>2.1.0</Version>
13+
<Authors>EasyAppDev</Authors>
14+
<Description>Source generators for EasyAppDev.Blazor.Store - reduces boilerplate code for state records</Description>
15+
<PackageTags>blazor;state-management;source-generator;zustand;flux</PackageTags>
16+
<PackageLicenseExpression>MIT</PackageLicenseExpression>
17+
<PackageProjectUrl>https://github.com/easyappdev/EasyAppDev.Blazor.Store</PackageProjectUrl>
18+
<RepositoryUrl>https://github.com/easyappdev/EasyAppDev.Blazor.Store</RepositoryUrl>
19+
<RepositoryType>git</RepositoryType>
20+
21+
<!-- Source Generator Config -->
22+
<IncludeBuildOutput>false</IncludeBuildOutput>
23+
<DevelopmentDependency>true</DevelopmentDependency>
24+
<IsRoslynComponent>true</IsRoslynComponent>
25+
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
26+
</PropertyGroup>
27+
28+
<ItemGroup>
29+
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4">
30+
<PrivateAssets>all</PrivateAssets>
31+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
32+
</PackageReference>
33+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />
34+
</ItemGroup>
35+
36+
<ItemGroup>
37+
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
38+
</ItemGroup>
39+
40+
</Project>
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
using System.Collections.Immutable;
2+
using System.Text;
3+
using Microsoft.CodeAnalysis;
4+
using Microsoft.CodeAnalysis.CSharp;
5+
using Microsoft.CodeAnalysis.CSharp.Syntax;
6+
using Microsoft.CodeAnalysis.Text;
7+
8+
namespace EasyAppDev.Blazor.Store.Generators;
9+
10+
/// <summary>
11+
/// Source generator that creates setter and updater methods for store state records.
12+
/// </summary>
13+
[Generator]
14+
public class StoreGenerator : IIncrementalGenerator
15+
{
16+
private const string StoreAttributeName = "EasyAppDev.Blazor.Store.Generators.StoreAttribute";
17+
private const string ImmutableCollectionAttributeName = "EasyAppDev.Blazor.Store.Generators.ImmutableCollectionAttribute";
18+
private const string ComputedAttributeName = "EasyAppDev.Blazor.Store.Generators.ComputedAttribute";
19+
20+
public void Initialize(IncrementalGeneratorInitializationContext context)
21+
{
22+
// Find all records with [Store] attribute
23+
var recordDeclarations = context.SyntaxProvider
24+
.ForAttributeWithMetadataName(
25+
StoreAttributeName,
26+
predicate: static (node, _) => node is RecordDeclarationSyntax,
27+
transform: static (ctx, _) => GetRecordInfo(ctx))
28+
.Where(static r => r is not null);
29+
30+
// Generate source for each record
31+
context.RegisterSourceOutput(recordDeclarations, static (ctx, record) =>
32+
{
33+
if (record is null) return;
34+
var source = GenerateSource(record);
35+
ctx.AddSource($"{record.Name}.g.cs", SourceText.From(source, Encoding.UTF8));
36+
});
37+
}
38+
39+
private static RecordInfo? GetRecordInfo(GeneratorAttributeSyntaxContext context)
40+
{
41+
var recordSymbol = context.TargetSymbol as INamedTypeSymbol;
42+
if (recordSymbol is null) return null;
43+
44+
var recordSyntax = context.TargetNode as RecordDeclarationSyntax;
45+
if (recordSyntax is null) return null;
46+
47+
// Get attribute data
48+
var storeAttribute = context.Attributes.FirstOrDefault(a =>
49+
a.AttributeClass?.ToDisplayString() == StoreAttributeName);
50+
51+
var generateActions = storeAttribute?.NamedArguments
52+
.FirstOrDefault(kvp => kvp.Key == "GenerateActions")
53+
.Value.Value as bool? ?? false;
54+
55+
var generateWithMethods = storeAttribute?.NamedArguments
56+
.FirstOrDefault(kvp => kvp.Key == "GenerateWithMethods")
57+
.Value.Value as bool? ?? false;
58+
59+
// Get namespace
60+
var ns = recordSymbol.ContainingNamespace.IsGlobalNamespace
61+
? null
62+
: recordSymbol.ContainingNamespace.ToDisplayString();
63+
64+
// Get properties from primary constructor parameters
65+
var properties = new List<PropertyInfo>();
66+
67+
foreach (var member in recordSymbol.GetMembers())
68+
{
69+
if (member is not IPropertySymbol property) continue;
70+
if (property.IsStatic || property.IsIndexer) continue;
71+
if (!property.CanBeReferencedByName) continue;
72+
73+
// Skip synthesized record properties
74+
if (property.Name == "EqualityContract") continue;
75+
if (property.IsImplicitlyDeclared) continue;
76+
77+
// Check for Computed attribute
78+
var hasComputed = property.GetAttributes().Any(a =>
79+
a.AttributeClass?.ToDisplayString() == ComputedAttributeName);
80+
if (hasComputed) continue;
81+
82+
// Check for ImmutableCollection attribute
83+
var hasImmutableCollection = property.GetAttributes().Any(a =>
84+
a.AttributeClass?.ToDisplayString() == ImmutableCollectionAttributeName);
85+
86+
// Get generic type for collections
87+
string? elementType = null;
88+
if (hasImmutableCollection && property.Type is INamedTypeSymbol namedType)
89+
{
90+
if (namedType.TypeArguments.Length > 0)
91+
{
92+
elementType = namedType.TypeArguments[0].ToDisplayString();
93+
}
94+
}
95+
96+
properties.Add(new PropertyInfo(
97+
property.Name,
98+
property.Type.ToDisplayString(),
99+
property.NullableAnnotation == NullableAnnotation.Annotated,
100+
hasImmutableCollection,
101+
elementType));
102+
}
103+
104+
return new RecordInfo(
105+
recordSymbol.Name,
106+
ns,
107+
properties,
108+
generateActions,
109+
generateWithMethods);
110+
}
111+
112+
private static string GenerateSource(RecordInfo record)
113+
{
114+
var sb = new StringBuilder();
115+
116+
sb.AppendLine("// <auto-generated/>");
117+
sb.AppendLine("#nullable enable");
118+
sb.AppendLine();
119+
120+
if (record.Namespace is not null)
121+
{
122+
sb.AppendLine($"namespace {record.Namespace};");
123+
sb.AppendLine();
124+
}
125+
126+
sb.AppendLine($"public partial record {record.Name}");
127+
sb.AppendLine("{");
128+
129+
foreach (var prop in record.Properties)
130+
{
131+
GeneratePropertyMethods(sb, record.Name, prop, record.GenerateWithMethods);
132+
}
133+
134+
sb.AppendLine("}");
135+
136+
// Generate actions if requested
137+
if (record.GenerateActions)
138+
{
139+
sb.AppendLine();
140+
GenerateActions(sb, record);
141+
}
142+
143+
return sb.ToString();
144+
}
145+
146+
private static void GeneratePropertyMethods(
147+
StringBuilder sb,
148+
string recordName,
149+
PropertyInfo prop,
150+
bool generateWith)
151+
{
152+
var propName = prop.Name;
153+
var propType = prop.Type;
154+
155+
// Set method
156+
sb.AppendLine($" /// <summary>Sets the {propName} property.</summary>");
157+
sb.AppendLine($" public {recordName} Set{propName}({propType} value)");
158+
sb.AppendLine($" => this with {{ {propName} = value }};");
159+
sb.AppendLine();
160+
161+
// With method (alias for Set)
162+
if (generateWith)
163+
{
164+
sb.AppendLine($" /// <summary>Sets the {propName} property (alias for Set{propName}).</summary>");
165+
sb.AppendLine($" public {recordName} With{propName}({propType} value)");
166+
sb.AppendLine($" => this with {{ {propName} = value }};");
167+
sb.AppendLine();
168+
}
169+
170+
// Update method
171+
sb.AppendLine($" /// <summary>Updates the {propName} property using a transform function.</summary>");
172+
sb.AppendLine($" public {recordName} Update{propName}(System.Func<{propType}, {propType}> updater)");
173+
sb.AppendLine($" => this with {{ {propName} = updater({propName}) }};");
174+
sb.AppendLine();
175+
176+
// Collection methods
177+
if (prop.IsImmutableCollection && prop.ElementType is not null)
178+
{
179+
var elemType = prop.ElementType;
180+
181+
// Add method
182+
sb.AppendLine($" /// <summary>Adds an item to {propName}.</summary>");
183+
sb.AppendLine($" public {recordName} Add{GetSingular(propName)}({elemType} item)");
184+
sb.AppendLine($" => this with {{ {propName} = {propName}.Add(item) }};");
185+
sb.AppendLine();
186+
187+
// Remove method
188+
sb.AppendLine($" /// <summary>Removes an item from {propName}.</summary>");
189+
sb.AppendLine($" public {recordName} Remove{GetSingular(propName)}({elemType} item)");
190+
sb.AppendLine($" => this with {{ {propName} = {propName}.Remove(item) }};");
191+
sb.AppendLine();
192+
193+
// Clear method
194+
sb.AppendLine($" /// <summary>Clears all items from {propName}.</summary>");
195+
sb.AppendLine($" public {recordName} Clear{propName}()");
196+
sb.AppendLine($" => this with {{ {propName} = {propName}.Clear() }};");
197+
sb.AppendLine();
198+
}
199+
}
200+
201+
private static void GenerateActions(StringBuilder sb, RecordInfo record)
202+
{
203+
sb.AppendLine($"/// <summary>Generated actions for {record.Name}.</summary>");
204+
sb.AppendLine($"public static partial class {record.Name}Actions");
205+
sb.AppendLine("{");
206+
207+
foreach (var prop in record.Properties)
208+
{
209+
sb.AppendLine($" /// <summary>Action to set {prop.Name}.</summary>");
210+
sb.AppendLine($" public record Set{prop.Name}({prop.Type} Value) : EasyAppDev.Blazor.Store.Actions.IAction;");
211+
sb.AppendLine();
212+
}
213+
214+
sb.AppendLine("}");
215+
216+
// Generate reducer extension
217+
sb.AppendLine();
218+
sb.AppendLine($"/// <summary>Generated reducer extension for {record.Name}.</summary>");
219+
sb.AppendLine($"public static partial class {record.Name}ReducerExtensions");
220+
sb.AppendLine("{");
221+
sb.AppendLine($" /// <summary>Registers all generated reducers for {record.Name} actions.</summary>");
222+
sb.AppendLine($" public static EasyAppDev.Blazor.Store.Actions.ActionDispatcher<{record.Name}> WithGeneratedReducers(");
223+
sb.AppendLine($" this EasyAppDev.Blazor.Store.Actions.ActionDispatcher<{record.Name}> dispatcher)");
224+
sb.AppendLine(" {");
225+
sb.AppendLine(" return dispatcher");
226+
227+
foreach (var prop in record.Properties)
228+
{
229+
sb.AppendLine($" .Register<{record.Name}Actions.Set{prop.Name}>((s, a) => s.Set{prop.Name}(a.Value))");
230+
}
231+
232+
// Remove the last newline and add semicolon
233+
sb.Length -= "\r\n".Length; // Assume Windows line endings; cross-platform handled by StringBuilder
234+
if (sb[sb.Length - 1] == '\n') sb.Length--; // Handle Unix line endings
235+
if (sb.Length > 0 && sb[sb.Length - 1] == '\r') sb.Length--;
236+
sb.AppendLine(";");
237+
sb.AppendLine(" }");
238+
sb.AppendLine("}");
239+
}
240+
241+
private static string GetSingular(string plural)
242+
{
243+
// Simple singularization - remove trailing 's' or 'es'
244+
if (plural.EndsWith("ies", StringComparison.Ordinal))
245+
return plural.Substring(0, plural.Length - 3) + "y";
246+
if (plural.EndsWith("es", StringComparison.Ordinal))
247+
return plural.Substring(0, plural.Length - 2);
248+
if (plural.EndsWith("s", StringComparison.Ordinal))
249+
return plural.Substring(0, plural.Length - 1);
250+
return plural;
251+
}
252+
253+
private sealed class RecordInfo
254+
{
255+
public string Name { get; }
256+
public string? Namespace { get; }
257+
public List<PropertyInfo> Properties { get; }
258+
public bool GenerateActions { get; }
259+
public bool GenerateWithMethods { get; }
260+
261+
public RecordInfo(
262+
string name,
263+
string? ns,
264+
List<PropertyInfo> properties,
265+
bool generateActions,
266+
bool generateWithMethods)
267+
{
268+
Name = name;
269+
Namespace = ns;
270+
Properties = properties;
271+
GenerateActions = generateActions;
272+
GenerateWithMethods = generateWithMethods;
273+
}
274+
}
275+
276+
private sealed class PropertyInfo
277+
{
278+
public string Name { get; }
279+
public string Type { get; }
280+
public bool IsNullable { get; }
281+
public bool IsImmutableCollection { get; }
282+
public string? ElementType { get; }
283+
284+
public PropertyInfo(
285+
string name,
286+
string type,
287+
bool isNullable,
288+
bool isImmutableCollection,
289+
string? elementType)
290+
{
291+
Name = name;
292+
Type = type;
293+
IsNullable = isNullable;
294+
IsImmutableCollection = isImmutableCollection;
295+
ElementType = elementType;
296+
}
297+
}
298+
}

0 commit comments

Comments
 (0)