diff --git a/.github/workflows/dev-pr-build.yml b/.github/workflows/dev-pr-build.yml
index e155769bd..727a27b42 100644
--- a/.github/workflows/dev-pr-build.yml
+++ b/.github/workflows/dev-pr-build.yml
@@ -56,8 +56,10 @@ jobs:
USE_CONTAINER: true
ARCGIS_API_KEY: ${{ secrets.ARCGIS_TESTING_API_KEY }}
GEOBLAZOR_CORE_LICENSE_KEY: ${{ secrets.GEOBLAZOR_CORE_LICENSE_KEY }}
+ HTTP_PORT: 8082
+ HTTPS_PORT: 9445
run: |
- dotnet test --project ./test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj -c Release --filter CORE_ --max-parallel-test-modules 2 /p:GeneratePackage=false /p:GenerateDocs=false
+ dotnet test --project ./test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj -c Release --filter CORE_
build:
runs-on: ubuntu-latest
diff --git a/.github/workflows/main-release-build.yml b/.github/workflows/main-release-build.yml
index 1a6ac4b0c..ab4a040ff 100644
--- a/.github/workflows/main-release-build.yml
+++ b/.github/workflows/main-release-build.yml
@@ -41,8 +41,10 @@ jobs:
USE_CONTAINER: true
ARCGIS_API_KEY: ${{ secrets.ARCGIS_TESTING_API_KEY }}
GEOBLAZOR_CORE_LICENSE_KEY: ${{ secrets.GEOBLAZOR_CORE_LICENSE_KEY }}
+ HTTP_PORT: 8082
+ HTTPS_PORT: 9445
run: |
- dotnet test --project ./test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj -c Release --filter CORE_ --max-parallel-test-modules 2 /p:GeneratePackage=false /p:GenerateDocs=false
+ dotnet test --project ./test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj -c Release --filter CORE_
# This runs the main GeoBlazor build script
- name: Build GeoBlazor
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 70b0c17af..d6ab46c3c 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -40,5 +40,7 @@ jobs:
USE_CONTAINER: true
ARCGIS_API_KEY: ${{ secrets.ARCGIS_TESTING_API_KEY }}
GEOBLAZOR_CORE_LICENSE_KEY: ${{ secrets.GEOBLAZOR_CORE_LICENSE_KEY }}
+ HTTP_PORT: 8082
+ HTTPS_PORT: 9445
run: |
- dotnet test --project ./test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj -c Release --filter CORE_ --max-parallel-test-modules 2 /p:GeneratePackage=false /p:GenerateDocs=false
\ No newline at end of file
+ dotnet test --project ./test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj -c Release --filter CORE_
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 88ce23af4..1fd10f24f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,6 +19,8 @@ CustomerTests.razor
test/dymaptic.GeoBlazor.Core.Test.Automation/test.txt
test/dymaptic.GeoBlazor.Core.Test.Automation/test-run.log
test/dymaptic.GeoBlazor.Core.Test.Automation/coverage*
+test/dymaptic.GeoBlazor.Core.Test.Automation/unit-coverage*
+test/dymaptic.GeoBlazor.Core.Test.Automation/sgen-coverage*
test/dymaptic.GeoBlazor.Core.Test.Automation/Summary.txt
# User-specific files (MonoDevelop/Xamarin Studio)
diff --git a/Directory.Build.props b/Directory.Build.props
index 6a3345c08..0c202811e 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,18 +1,26 @@
-
+
enable
enable
- 5.0.0.8
+ 5.0.0.13
true
+ Debug;Release;SourceGen Highlighting
+ AnyCPU
$(MSBuildThisFileDirectory)src\dymaptic.GeoBlazor.Core
- $(StaticWebAssetEndpointExclusionPattern);js/**
-
+ $(StaticWebAssetEndpointExclusionPattern);js/**
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index c4aa578cc..55b675c51 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -107,7 +107,7 @@ ENV ASPNETCORE_Kestrel__Certificates__Default__Password=password
# Coverage configuration (can be overridden via environment)
ENV COVERAGE_ENABLED=false
-ENV COVERAGE_OUTPUT=/coverage/coverage.xml
+ENV COVERAGE_FORMAT=xml
# Copy entrypoint script
COPY ./test/dymaptic.GeoBlazor.Core.Test.Automation/docker-entrypoint.sh /docker-entrypoint.sh
diff --git a/ReadMe.md b/ReadMe.md
index 99b707036..09c5a59de 100644
--- a/ReadMe.md
+++ b/ReadMe.md
@@ -17,9 +17,10 @@ GeoBlazor brings the power of the ArcGIS Maps SDK for JavaScript into your Blazo
[](https://github.com/dymaptic/GeoBlazor/actions/workflows/main-release-build.yml)
[](https://github.com/dymaptic/GeoBlazor/issues)
[](https://github.com/dymaptic/GeoBlazor/pulls)
-[]
-[]
-[]
+
+
+
+
**CORE**
diff --git a/badge_fullmethodcoverage.svg b/badge_fullmethodcoverage.svg
new file mode 100644
index 000000000..1888c66f1
--- /dev/null
+++ b/badge_fullmethodcoverage.svg
@@ -0,0 +1,144 @@
+
\ No newline at end of file
diff --git a/badge_linecoverage.svg b/badge_linecoverage.svg
new file mode 100644
index 000000000..f2be831f2
--- /dev/null
+++ b/badge_linecoverage.svg
@@ -0,0 +1,138 @@
+
\ No newline at end of file
diff --git a/badge_methodcoverage.svg b/badge_methodcoverage.svg
new file mode 100644
index 000000000..820772278
--- /dev/null
+++ b/badge_methodcoverage.svg
@@ -0,0 +1,138 @@
+
\ No newline at end of file
diff --git a/esBuildWaitForCompletion.ps1 b/esBuildWaitForCompletion.ps1
index 8a0ac25ba..c7254199f 100644
--- a/esBuildWaitForCompletion.ps1
+++ b/esBuildWaitForCompletion.ps1
@@ -15,9 +15,27 @@ if ((Test-Path -Path $CoreLockFilePath) -or (Test-Path -Path $ProLockFilePath))
return 0
}
+$timeout = 30
+$elapsed = 0
+
while ((Test-Path -Path $CoreLockFilePath) -or (Test-Path -Path $ProLockFilePath)) {
Start-Sleep -Seconds 1
Write-Host -NoNewline "."
+ $elapsed++
+
+ if ($elapsed -ge $timeout) {
+ Write-Host ""
+ Write-Host "Timeout reached ($timeout seconds). Deleting lock files."
+ if (Test-Path -Path $CoreLockFilePath) {
+ Remove-Item -Path $CoreLockFilePath -Force
+ Write-Host "Deleted: $CoreLockFilePath"
+ }
+ if (Test-Path -Path $ProLockFilePath) {
+ Remove-Item -Path $ProLockFilePath -Force
+ Write-Host "Deleted: $ProLockFilePath"
+ }
+ break
+ }
}
Write-Host "Lock file removed. Exiting."
diff --git a/src/.editorconfig b/src/.editorconfig
index b499c6efd..9415adabd 100644
--- a/src/.editorconfig
+++ b/src/.editorconfig
@@ -130,7 +130,7 @@ csharp_style_prefer_readonly_struct_member = true
csharp_prefer_braces = true
csharp_prefer_simple_using_statement = true
csharp_prefer_system_threading_lock = true
-csharp_style_namespace_declarations = block_scoped
+csharp_style_namespace_declarations = file_scoped
csharp_style_prefer_method_group_conversion = true
csharp_style_prefer_primary_constructors = true
csharp_style_prefer_simple_property_accessors = true
@@ -240,10 +240,6 @@ dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
-dotnet_naming_rule.private_constants_should_be_pascal_case.severity = suggestion
-dotnet_naming_rule.private_constants_should_be_pascal_case.symbols = private_constants
-dotnet_naming_rule.private_constants_should_be_pascal_case.style = pascal_case
-
dotnet_naming_rule.private_static_readonly_should_be_camel_case.severity = suggestion
dotnet_naming_rule.private_static_readonly_should_be_camel_case.symbols = private_static_readonly
dotnet_naming_rule.private_static_readonly_should_be_camel_case.style = camel_case
diff --git a/src/dymaptic.GeoBlazor.Core.Analyzers/ComponentConstructorValidator.cs b/src/dymaptic.GeoBlazor.Core.Analyzers/ComponentConstructorValidator.cs
index aa0c03fa7..a87245286 100644
--- a/src/dymaptic.GeoBlazor.Core.Analyzers/ComponentConstructorValidator.cs
+++ b/src/dymaptic.GeoBlazor.Core.Analyzers/ComponentConstructorValidator.cs
@@ -8,6 +8,8 @@ namespace dymaptic.GeoBlazor.Core.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
internal class ComponentConstructorValidator : DiagnosticAnalyzer
{
+ public override ImmutableArray SupportedDiagnostics => [rule];
+
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
@@ -15,13 +17,6 @@ public override void Initialize(AnalysisContext context)
context.RegisterSymbolAction(ValidateSymbol, SymbolKind.NamedType);
}
- private static readonly DiagnosticDescriptor rule = new("GeoBlazor_AUC", "Missing ActivatorUtilitiesConstructorAttribute",
- "Class '{0}' has multiple constructors but does not have a parameterless constructor with the [ActivatorUtilitiesConstructor] attribute. Add [ActivatorUtilitiesConstructor] to the parameterless constructor to enable proper dependency injection.",
- "Usage", DiagnosticSeverity.Error, isEnabledByDefault: true,
- description: "When a Blazor component has multiple constructors, the parameterless constructor must be marked with [ActivatorUtilitiesConstructor] to ensure proper instantiation. Example: [ActivatorUtilitiesConstructor] public YourComponent() { }.");
-
- public override ImmutableArray SupportedDiagnostics => [rule];
-
private void ValidateSymbol(SymbolAnalysisContext context)
{
INamedTypeSymbol classSymbol = (INamedTypeSymbol)context.Symbol;
@@ -34,6 +29,7 @@ private void ValidateSymbol(SymbolAnalysisContext context)
// more than one constructor, at least one with parameters,
if (constructors.Any(c => c.Parameters.Length > 0)
+
// and no parameterless constructor with the attribute
&& constructors.All(c => c.Parameters.Length > 0 || !HasActivatorUtilitiesConstructorAttribute(c)))
{
@@ -55,7 +51,7 @@ bool IsRazorComponent(INamedTypeSymbol classSymbol)
{
return true;
}
-
+
baseType = baseType.BaseType;
}
@@ -64,7 +60,15 @@ bool IsRazorComponent(INamedTypeSymbol classSymbol)
bool HasActivatorUtilitiesConstructorAttribute(IMethodSymbol constructor)
{
- return constructor.GetAttributes().Any(attr =>
- attr.AttributeClass?.Name == "ActivatorUtilitiesConstructorAttribute");
+ return constructor.GetAttributes()
+ .Any(attr =>
+ attr.AttributeClass?.Name == "ActivatorUtilitiesConstructorAttribute");
}
+
+ private static readonly DiagnosticDescriptor rule = new("GeoBlazor_AUC",
+ "Missing ActivatorUtilitiesConstructorAttribute",
+ "Class '{0}' has multiple constructors but does not have a parameterless constructor with the [ActivatorUtilitiesConstructor] attribute. Add [ActivatorUtilitiesConstructor] to the parameterless constructor to enable proper dependency injection.",
+ "Usage", DiagnosticSeverity.Error, isEnabledByDefault: true,
+ description:
+ "When a Blazor component has multiple constructors, the parameterless constructor must be marked with [ActivatorUtilitiesConstructor] to ensure proper instantiation. Example: [ActivatorUtilitiesConstructor] public YourComponent() { }.");
}
\ No newline at end of file
diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProcessHelper.cs b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProcessHelper.cs
new file mode 100644
index 000000000..e96a1822f
--- /dev/null
+++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProcessHelper.cs
@@ -0,0 +1,168 @@
+using CliWrap;
+using CliWrap.EventStream;
+using Microsoft.CodeAnalysis;
+using System.Runtime.InteropServices;
+using System.Text;
+
+
+namespace dymaptic.GeoBlazor.Core.SourceGenerator.Shared;
+
+///
+/// Provides helper methods for executing external processes, PowerShell scripts, and logging diagnostics.
+///
+public static class ProcessHelper
+{
+ ///
+ /// Executes a PowerShell script file with the specified arguments.
+ ///
+ /// A descriptive name for the process, used in logging.
+ /// The working directory for the script execution.
+ /// The name of the PowerShell script file to execute.
+ /// Command-line arguments to pass to the script.
+ /// A StringBuilder to accumulate log output.
+ /// A cancellation token to cancel the operation.
+ /// Optional environment variables to set for the process.
+ public static async Task RunPowerShellScript(string processName, string workingDirectory,
+ string powershellScriptName, string arguments, StringBuilder logBuilder, CancellationToken token,
+ Dictionary? environmentVariables = null)
+ {
+ // Since we are always providing the scripts, this is safe to call `ByPass`
+ string shellArguments = $"-NoProfile -ExecutionPolicy ByPass -File \"{
+ Path.Combine(workingDirectory, powershellScriptName)}\" {arguments}";
+
+ await Execute(processName, workingDirectory, "pwsh", shellArguments, logBuilder, token,
+ environmentVariables);
+ }
+
+ ///
+ /// Executes a PowerShell command directly without a script file.
+ ///
+ /// A descriptive name for the process, used in logging.
+ /// The working directory for the command execution.
+ /// The PowerShell command to execute.
+ /// A StringBuilder to accumulate log output.
+ /// A cancellation token to cancel the operation.
+ /// Optional environment variables to set for the process.
+ public static async Task RunPowerShellCommand(string processName, string workingDirectory,
+ string arguments, StringBuilder logBuilder, CancellationToken token,
+ Dictionary? environmentVariables = null)
+ {
+ string shellArguments = $"-NoProfile -ExecutionPolicy ByPass -Command {{ {arguments} }}";
+
+ await Execute(processName, workingDirectory, "pwsh", shellArguments, logBuilder, token,
+ environmentVariables);
+ }
+
+ ///
+ /// Executes an external process with the specified arguments and captures its output.
+ ///
+ /// A descriptive name for the process, used in logging.
+ /// The working directory for the process execution.
+ /// The executable file name. If null, uses the platform-specific shell command.
+ /// Command-line arguments to pass to the process.
+ /// A StringBuilder to accumulate log output.
+ /// A cancellation token to cancel the operation.
+ /// Optional environment variables to set for the process.
+ /// Thrown when the process exits with a non-zero exit code.
+ public static async Task Execute(string processName, string workingDirectory, string? fileName,
+ string shellArguments, StringBuilder logBuilder, CancellationToken token,
+ Dictionary? environmentVariables = null)
+ {
+ fileName ??= shellCommand;
+
+ StringBuilder outputBuilder = new();
+ int? processId = null;
+ int? exitCode = null;
+
+ token.Register(() =>
+ {
+ logBuilder.AppendLine($"{processName}: Command execution cancelled.");
+ logBuilder.AppendLine(outputBuilder.ToString());
+ outputBuilder.Clear();
+ });
+
+ Command cmd = Cli.Wrap(fileName)
+ .WithArguments(shellArguments)
+ .WithWorkingDirectory(workingDirectory)
+ .WithValidation(CommandResultValidation.None)
+ .WithEnvironmentVariables(environmentVariables ?? new Dictionary());
+
+ await foreach (CommandEvent cmdEvent in cmd.ListenAsync(cancellationToken: token))
+ {
+ switch (cmdEvent)
+ {
+ case StartedCommandEvent started:
+ processId = started.ProcessId;
+ outputBuilder.AppendLine($"{processName} Process started: {started.ProcessId}");
+
+ outputBuilder.AppendLine($"{processName} - PID {processId}: Executing command: {fileName} {
+ shellArguments}");
+
+ break;
+ case StandardOutputCommandEvent stdOut:
+ string line = stdOut.Text.Trim();
+ outputBuilder.AppendLine($"{processName} - PID {processId}: [stdout] {line}");
+
+ break;
+ case StandardErrorCommandEvent stdErr:
+ outputBuilder.AppendLine($"{processName} - PID {processId}: [stderr] {stdErr.Text}");
+
+ break;
+ case ExitedCommandEvent exited:
+ exitCode = exited.ExitCode;
+
+ outputBuilder.AppendLine($"{processName} - PID {processId}: Process exited with code: {
+ exited.ExitCode}");
+
+ break;
+ }
+ }
+
+ // Append any accumulated output to the log
+ if (outputBuilder.Length > 0)
+ {
+ logBuilder.AppendLine(outputBuilder.ToString());
+ }
+
+ if (exitCode != 0)
+ {
+ throw new ProcessException($"{processName}: Error executing command '{shellArguments}' for process {
+ processId}. Exit code: {exitCode}");
+ }
+
+ // Return the standard output if the process completed normally
+ logBuilder.AppendLine($"{processName}: Command '{shellArguments}' completed successfully on process {processId
+ }.");
+ }
+
+ ///
+ /// Logs a diagnostic message to the source generator context.
+ ///
+ /// The title of the diagnostic message.
+ /// The diagnostic message content.
+ /// The severity level of the diagnostic.
+ /// The source production context to report the diagnostic to.
+ public static void Log(string title, string message, DiagnosticSeverity severity,
+ SourceProductionContext context)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(new DiagnosticDescriptor("GBSourceGen",
+ title,
+ message,
+ "Logging",
+ severity,
+ isEnabledByDefault: true), Location.None));
+ }
+
+ private static readonly string shellCommand = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
+ ? WindowsShell
+ : LinuxShell;
+
+ private const string LinuxShell = "/bin/bash";
+ private const string WindowsShell = "cmd";
+}
+
+///
+/// Exception thrown when an external process execution fails.
+///
+/// The error message describing the failure.
+public class ProcessException(string message) : Exception(message);
\ No newline at end of file
diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProtobufDefinitionsGenerator.cs b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProtobufDefinitionsGenerator.cs
new file mode 100644
index 000000000..2f5ca8b3d
--- /dev/null
+++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/ProtobufDefinitionsGenerator.cs
@@ -0,0 +1,431 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using System.Collections.Immutable;
+using System.Text;
+using System.Text.RegularExpressions;
+
+
+namespace dymaptic.GeoBlazor.Core.SourceGenerator.Shared;
+
+///
+/// Generates Protobuf definitions by invoking the ProtoGen project.
+///
+public static class ProtobufDefinitionsGenerator
+{
+ public static Dictionary UpdateProtobufDefinitions(SourceProductionContext context,
+ ImmutableArray types, string corePath)
+ {
+ ProcessHelper.Log(nameof(ProtobufDefinitionsGenerator),
+ "Updating Protobuf definitions...",
+ DiagnosticSeverity.Info,
+ context);
+
+ // fetch protobuf definitions
+ var protoTypeContent = Generate(context, types);
+
+ var typescriptContent = $"""
+ export let protoTypeDefinitions: string = `
+ {protoTypeContent}
+ `;
+ """;
+
+ var encoded = escapeRegex.Replace(typescriptContent, match => match.Value switch
+ {
+ "\"" => "\\\"",
+ "\r" => "\\r",
+ "\n" => "\\n",
+ _ => match.Value
+ });
+ StringBuilder logBuilder = new();
+
+ var scriptPath = Path.Combine(corePath, "copyProtobuf.ps1");
+
+ // write protobuf definitions to geoblazorProto.ts
+ // must use GetAwaiter().GetResult(), since Source Generator is not Async
+ ProcessHelper.RunPowerShellScript("Copy Protobuf Definitions",
+ corePath, scriptPath,
+ $"-Content \"{encoded}\"",
+ logBuilder, context.CancellationToken)
+ .GetAwaiter()
+ .GetResult();
+
+ ProcessHelper.Log(nameof(ProtobufDefinitionsGenerator),
+ logBuilder.ToString(),
+ DiagnosticSeverity.Info,
+ context);
+
+ ProcessHelper.Log(nameof(ProtobufDefinitionsGenerator),
+ "Protobuf definitions updated successfully.",
+ DiagnosticSeverity.Info,
+ context);
+
+ return _protoDefinitions ?? [];
+ }
+
+ private static string Generate(SourceProductionContext context,
+ ImmutableArray types)
+ {
+ try
+ {
+ ProcessHelper.Log(nameof(ProtobufDefinitionsGenerator),
+ "Generating Protobuf schema",
+ DiagnosticSeverity.Info,
+ context);
+
+ // Extract protobuf definitions from syntax nodes
+ _protoDefinitions ??= ExtractProtobufDefinitions(types, context);
+
+ ProcessHelper.Log(nameof(ProtobufDefinitionsGenerator),
+ $"Extracted {_protoDefinitions.Count} Protobuf message definitions.",
+ DiagnosticSeverity.Info,
+ context);
+
+ // Generate new proto file content
+ var newProtoContent = GenerateProtoFileContent(_protoDefinitions);
+
+ ProcessHelper.Log(nameof(ProtobufDefinitionsGenerator),
+ "Protobuf schema generation complete",
+ DiagnosticSeverity.Info,
+ context);
+
+ return newProtoContent;
+ }
+ catch (Exception ex)
+ {
+ ProcessHelper.Log(nameof(ProtobufDefinitionsGenerator),
+ $"Error generating Protobuf definitions: {ex.Message}",
+ DiagnosticSeverity.Error,
+ context);
+
+ return string.Empty;
+ }
+ }
+
+ private static Dictionary ExtractProtobufDefinitions(
+ ImmutableArray types, SourceProductionContext context)
+ {
+ var definitions = new Dictionary();
+ const string protoContractAttribute = "ProtoContract";
+
+ foreach (var type in types)
+ {
+ if (type.AttributeLists.SelectMany(a => a.Attributes)
+ .All(a => a.Name.ToString() != protoContractAttribute))
+ {
+ if (type.Identifier.Text.EndsWith("SerializationRecord")
+ && (type.Identifier.Text != "MapComponentSerializationRecord")
+ && (type.Identifier.Text != "MapComponentCollectionSerializationRecord"))
+ {
+ ProcessHelper.Log(nameof(ProtobufDefinitionsGenerator),
+ $"Processing syntax node: {type.Identifier.Text
+ }, which is a SerializationRecord without ProtoContract attribute. Attributes: {
+ string.Join(", ",
+ type.AttributeLists.SelectMany(al => al.Attributes.SelectMany(a => a.ToString())))
+ }",
+ DiagnosticSeverity.Warning,
+ context);
+ }
+
+ continue;
+ }
+
+ try
+ {
+ var messageDef = ExtractMessageDefinition(type);
+
+ if (messageDef != null)
+ {
+ definitions[messageDef.Name] = messageDef;
+ }
+ }
+ catch (Exception ex)
+ {
+ ProcessHelper.Log(nameof(ProtobufDefinitionsGenerator),
+ $"Error processing syntax node {type.Identifier.Text}: {ex.Message}",
+ DiagnosticSeverity.Warning,
+ context);
+ }
+ }
+
+ return definitions;
+ }
+
+ private static ProtoMessageDefinition? ExtractMessageDefinition(BaseTypeDeclarationSyntax syntaxNode)
+ {
+ // Get ProtoContract attribute to find the message name
+ var protoContractAttr = syntaxNode.AttributeLists
+ .SelectMany(al => al.Attributes)
+ .FirstOrDefault(a => a.Name.ToString().Contains("ProtoContract"));
+
+ if (protoContractAttr == null)
+ {
+ return null;
+ }
+
+ // Extract the Name parameter from ProtoContract attribute
+ var messageName = syntaxNode.Identifier.Text;
+
+ var nameArg = protoContractAttr.ArgumentList?.Arguments
+ .FirstOrDefault(arg => arg.NameEquals?.Name.Identifier.Text == "Name");
+
+ if (nameArg is { Expression: LiteralExpressionSyntax literal })
+ {
+ messageName = literal.Token.ValueText;
+ }
+
+ var fields = new List();
+ var protoIncludeFields = new List();
+
+ // Extract ProtoInclude attributes for oneof fields
+ var protoIncludeAttrs = syntaxNode.AttributeLists
+ .SelectMany(al => al.Attributes)
+ .Where(a => a.Name.ToString().Contains("ProtoInclude"));
+
+ var baseType = syntaxNode.BaseList?.Types.FirstOrDefault();
+ var geoBlazorTypeIsInterface = false;
+
+ if (!messageName.EndsWith("Collection") && baseType is not null)
+ {
+ var baseTypeName = baseType.Type.ToString();
+ var innerTypeIndex = baseTypeName.IndexOf("<", StringComparison.OrdinalIgnoreCase) + 1;
+
+ var geoBlazorTypeName =
+ baseTypeName.Substring(innerTypeIndex, baseTypeName.Length - innerTypeIndex - 1);
+
+ if ((geoBlazorTypeName[0] == 'I') && char.IsUpper(geoBlazorTypeName[1]))
+ {
+ geoBlazorTypeIsInterface = true;
+ }
+ }
+
+ foreach (var includeAttr in protoIncludeAttrs)
+ {
+ if (includeAttr.ArgumentList?.Arguments.Count >= 2)
+ {
+ var tagArg = includeAttr.ArgumentList.Arguments[0].Expression;
+ var typeArg = includeAttr.ArgumentList.Arguments[1].Expression;
+
+ if (tagArg is LiteralExpressionSyntax tagLiteral &&
+ int.TryParse(tagLiteral.Token.ValueText, out var tag))
+ {
+ var typeName = ExtractTypeFromExpression(typeArg);
+ protoIncludeFields.Add(new ProtoIncludeDefinition { Tag = tag, TypeName = typeName });
+ }
+ }
+ }
+
+ // Extract fields with ProtoMember attributes
+ foreach (var member in syntaxNode.ChildNodes())
+ {
+ if (member is PropertyDeclarationSyntax property)
+ {
+ var protoMemberAttr = property.AttributeLists
+ .SelectMany(al => al.Attributes)
+ .FirstOrDefault(a => a.Name.ToString().Contains("ProtoMember"));
+
+ if (protoMemberAttr is { ArgumentList.Arguments.Count: > 0 })
+ {
+ var fieldNumber = protoMemberAttr.ArgumentList.Arguments[0].Expression;
+
+ if (fieldNumber is LiteralExpressionSyntax fieldNumLiteral &&
+ int.TryParse(fieldNumLiteral.Token.ValueText, out var num))
+ {
+ var fieldType = ConvertCSharpTypeToProtoType(property.Type.ToString());
+
+ fields.Add(new ProtoFieldDefinition
+ {
+ Type = fieldType, Name = property.Identifier.Text.ToLowerFirstChar(), Number = num
+ });
+ }
+ }
+ }
+ }
+
+ return new ProtoMessageDefinition
+ {
+ Name = messageName,
+ Fields = fields.OrderBy(f => f.Number).ToList(),
+ ProtoIncludes = protoIncludeFields.OrderBy(p => p.Tag).ToList(),
+ GeoBlazorTypeIsInterface = geoBlazorTypeIsInterface
+ };
+ }
+
+ private static string ExtractTypeFromExpression(ExpressionSyntax expression)
+ {
+ // Handle typeof(TypeName) expression
+ if (expression is TypeOfExpressionSyntax typeOfExpr)
+ {
+ return typeOfExpr.Type.ToString();
+ }
+
+ return expression.ToString();
+ }
+
+ private static string ConvertCSharpTypeToProtoType(string csharpType)
+ {
+ // Check if it's an array type (need repeated keyword)
+ var isRepeated = (csharpType.Contains("[]") && (csharpType != "byte[]") && (csharpType != "byte[]?"))
+ || csharpType.Contains("IEnumerable")
+ || csharpType.Contains("List<");
+
+ // Remove nullable markers and array indicators
+ var cleanType = csharpType.Replace("?", "").Trim();
+
+ if (isRepeated)
+ {
+ cleanType = cleanType.Replace("[]", "")
+ .Replace("IEnumerable<", "")
+ .Replace("IList<", "")
+ .Replace("List<", "")
+ .Replace(">", "")
+ .Trim();
+ }
+
+ // Map C# types to proto types
+ var protoType = cleanType switch
+ {
+ "string" => "string",
+ "int" => "int32",
+ "long" => "int64",
+ "double" => "double",
+ "float" => "float",
+ "bool" => "bool",
+ "byte[]" => "bytes",
+ _ when cleanType.Contains("SerializationRecord") => cleanType.Replace("SerializationRecord", ""),
+ _ => cleanType
+ };
+
+ return isRepeated ? $"repeated {protoType}" : protoType;
+ }
+
+ private static string GenerateProtoFileContent(Dictionary definitions)
+ {
+ var sb = new StringBuilder();
+
+ // Header
+ sb.AppendLine("syntax = \"proto3\";");
+ sb.AppendLine("package dymaptic.GeoBlazor.Core.Serialization;");
+ sb.AppendLine("import \"google/protobuf/empty.proto\";");
+ sb.AppendLine();
+
+ // First, generate regular message definitions (those without ProtoIncludes or with both fields and includes)
+ foreach (var def in definitions.Values.OrderBy(d => d.Name))
+ {
+ if ((def.Name == "MapComponent") || (def.Name == "MapComponentCollection"))
+ {
+ continue; // Handle these special cases separately
+ }
+
+ sb.AppendLine($"message {def.Name} {{");
+
+ // Generate regular fields
+ foreach (var field in def.Fields)
+ {
+ sb.AppendLine($" {field.Type} {field.Name} = {field.Number};");
+ }
+
+ sb.AppendLine("}");
+ }
+
+ // Generate MapComponent with oneof for all the serialization records
+ if (definitions.TryGetValue("MapComponent", out var mapComponentDef) && mapComponentDef.ProtoIncludes.Any())
+ {
+ sb.AppendLine("message MapComponent {");
+ sb.AppendLine(" oneof subtype {");
+
+ foreach (var include in mapComponentDef.ProtoIncludes)
+ {
+ var typeName = include.TypeName.Replace("SerializationRecord", "");
+ sb.AppendLine($" {typeName} {typeName} = {include.Tag};");
+ }
+
+ sb.AppendLine(" }");
+ sb.AppendLine("}");
+ }
+
+ // Generate MapComponentCollection with oneof for all the collection types
+ if (definitions.TryGetValue("MapComponentCollection", out var collectionDef) &&
+ collectionDef.ProtoIncludes.Any())
+ {
+ sb.AppendLine("message MapComponentCollection {");
+ sb.AppendLine(" oneof subtype {");
+
+ foreach (var include in collectionDef.ProtoIncludes)
+ {
+ var typeName = include.TypeName.Replace("SerializationRecord", "");
+ sb.AppendLine($" {typeName} {typeName} = {include.Tag};");
+ }
+
+ sb.AppendLine(" }");
+ sb.AppendLine("}");
+ }
+
+ return sb.ToString();
+ }
+
+ private static readonly Regex escapeRegex = new(@"[""\r\n]", RegexOptions.Compiled);
+ private static Dictionary? _protoDefinitions;
+}
+
+///
+/// Represents a Protobuf message definition extracted from a C# type with ProtoContract attribute.
+///
+public class ProtoMessageDefinition
+{
+ ///
+ /// The name of the Protobuf message, derived from the ProtoContract Name parameter or the type identifier.
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// The list of fields in this message, extracted from properties with ProtoMember attributes.
+ ///
+ public List Fields { get; set; } = new();
+
+ ///
+ /// The list of ProtoInclude definitions for polymorphic serialization (oneof fields).
+ ///
+ public List ProtoIncludes { get; set; } = new();
+
+ ///
+ /// Indicates whether the corresponding GeoBlazor type is an interface.
+ ///
+ public bool GeoBlazorTypeIsInterface { get; set; }
+}
+
+///
+/// Represents a field within a Protobuf message definition.
+///
+public class ProtoFieldDefinition
+{
+ ///
+ /// The Protobuf type of the field (e.g., "string", "int32", "repeated Message").
+ ///
+ public string Type { get; set; } = string.Empty;
+
+ ///
+ /// The name of the field in camelCase format.
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// The field number from the ProtoMember attribute, used for wire format encoding.
+ ///
+ public int Number { get; set; }
+}
+
+///
+/// Represents a ProtoInclude attribute definition for polymorphic type hierarchies.
+///
+public class ProtoIncludeDefinition
+{
+ ///
+ /// The tag number used to identify this subtype in the Protobuf oneof field.
+ ///
+ public int Tag { get; set; }
+
+ ///
+ /// The name of the included subtype.
+ ///
+ public string TypeName { get; set; } = string.Empty;
+}
\ No newline at end of file
diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/SerializationGenerator.cs b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/SerializationGenerator.cs
new file mode 100644
index 000000000..4a291c4ea
--- /dev/null
+++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/SerializationGenerator.cs
@@ -0,0 +1,563 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using System.Collections.Immutable;
+using System.Text;
+
+
+namespace dymaptic.GeoBlazor.Core.SourceGenerator.Shared;
+
+///
+/// Generates serialization data for methods and classes marked with the appropriate attributes.
+///
+public static class SerializationGenerator
+{
+ ///
+ /// Generates a serialization data class containing pre-analyzed serialization information for Protobuf serialization.
+ ///
+ /// The source production context for adding generated source and reporting diagnostics.
+ /// The collection of type declarations to analyze for serialization attributes.
+ /// A dictionary of Protobuf message definitions keyed by type name.
+ /// Whether generating for Pro (true) or Core (false) library.
+ /// If true, skips file generation (for testing purposes).
+ public static void GenerateSerializationDataClass(SourceProductionContext context,
+ ImmutableArray types,
+ Dictionary protoMessageDefinitions,
+ bool isPro, bool isTest)
+ {
+ try
+ {
+ ProcessHelper.Log(nameof(SerializationGenerator),
+ "Generating serialized data class...",
+ DiagnosticSeverity.Info,
+ context);
+
+ ImmutableArray serializedMethodsCollection =
+ [
+ ..types
+ .SelectMany(t => t.ToSerializableMethodRecords())
+ ];
+
+ Dictionary protoSerializableTypes = [];
+
+ foreach (BaseTypeDeclarationSyntax type in types)
+ {
+ string typeName = type.Identifier.Text;
+
+ if (type.AttributeLists.SelectMany(a => a.Attributes)
+ .Any(a => a.Name.ToString() is ProtoSerializableAttribute))
+ {
+ if (protoMessageDefinitions.TryGetValue(typeName, out ProtoMessageDefinition? messageDefinition))
+ {
+ protoSerializableTypes[typeName] = messageDefinition;
+ }
+ }
+
+ // get the parent type
+ string? parentTypeName = type.BaseList?
+ .Types
+ .FirstOrDefault(bt => protoMessageDefinitions.ContainsKey(bt.ToString()))
+ ?
+ .ToString();
+
+ if (parentTypeName is not null)
+ {
+ // check parent type
+ var parentType =
+ types.FirstOrDefault(bt => bt.Identifier.Text == parentTypeName);
+
+ if (parentType?.AttributeLists.SelectMany(a => a.Attributes)
+ .Any(a => a.Name.ToString() is ProtoSerializableAttribute) == true)
+ {
+ protoSerializableTypes[typeName] = protoMessageDefinitions[parentTypeName];
+ }
+ }
+ }
+
+ string className = isPro ? "ProSerializationData" : "CoreSerializationData";
+
+ StringBuilder classBuilder = new($$"""
+ //
+
+ #nullable enable
+
+ {{(isPro ? "using dymaptic.GeoBlazor.Core.Serialization;" : "")}}
+ using System.Collections;
+ using FieldInfo = dymaptic.GeoBlazor.Core.Components.FieldInfo;
+
+ namespace dymaptic.GeoBlazor.{{(isPro ? "Pro" : "Core")}}.Serialization;
+
+ ///
+ /// This class is generated by a source generator and contains pre-analyzed serialization information.
+ ///
+ internal static class {{className}}
+ {
+
+ """);
+
+ if (!isPro)
+ {
+ classBuilder.AppendLine(GenerateExtensionMethods(protoSerializableTypes));
+ }
+
+ classBuilder.AppendLine(GenerateSerializableMethodRecords(serializedMethodsCollection, isPro));
+
+ classBuilder.AppendLine("}");
+
+ ProcessHelper.Log(nameof(SerializationGenerator),
+ $"Generated serialized data class: {className}.g.cs",
+ DiagnosticSeverity.Info,
+ context);
+
+ if (isTest)
+ {
+ ProcessHelper.Log(nameof(SerializationGenerator),
+ $"Skipping generating file for test.",
+ DiagnosticSeverity.Info,
+ context);
+ }
+ else
+ {
+ context.AddSource($"{className}.g.cs", classBuilder.ToString());
+ }
+ }
+ catch (Exception ex)
+ {
+ ProcessHelper.Log(nameof(SerializationGenerator),
+ $"Error generating serialized data class: {ex}",
+ DiagnosticSeverity.Error,
+ context);
+ }
+ }
+
+ private static string GenerateExtensionMethods(Dictionary protoDefinitions)
+ {
+ StringBuilder readJsProtoStreamRefMethod = new("""
+ ///
+ /// Convenience method to deserialize an to a specific type via protobuf.
+ ///
+ public static async Task ReadJsStreamReferenceAsProtobuf(this IJSStreamReference jsStreamReference,
+ Type returnType, long maxAllowedSize = 1_000_000_000)
+ {
+ await using Stream stream = await jsStreamReference.OpenReadStreamAsync(maxAllowedSize);
+ using MemoryStream memoryStream = new();
+ await stream.CopyToAsync(memoryStream);
+ memoryStream.Seek(0, SeekOrigin.Begin);
+
+ string typeName = returnType.Name.Replace("SerializationRecord", "");
+
+ switch (typeName)
+ {
+
+ """);
+
+ StringBuilder readJsProtoCollectionStreamRefMethod = new("""
+ ///
+ /// Convenience method to deserialize an to a specific coolection type via protobuf.
+ ///
+ public static async Task ReadJsStreamReferenceAsProtobufCollection(this IJSStreamReference jsStreamReference,
+ Type returnType, long maxAllowedSize = 1_000_000_000)
+ {
+ await using Stream stream = await jsStreamReference.OpenReadStreamAsync(maxAllowedSize);
+ using MemoryStream memoryStream = new();
+ await stream.CopyToAsync(memoryStream);
+ memoryStream.Seek(0, SeekOrigin.Begin);
+
+ string typeName = returnType.Name.Replace("SerializationRecord", "");
+
+ switch (typeName)
+ {
+
+ """);
+
+ StringBuilder protoTypeDictionary = new("""
+ ///
+ /// A collection of types that can be serialized to Protobuf
+ ///
+ public static Dictionary ProtoContractTypes => _protoContractTypes;
+
+ private static readonly Dictionary _protoContractTypes = new()
+ {
+
+ """);
+
+ StringBuilder protoCollectionTypeDictionary = new("""
+ ///
+ /// A collection of types that can be serialized to Protobuf as collections of a specific Type
+ ///
+ public static Dictionary ProtoCollectionTypes => _protoCollectionTypes;
+
+ private static readonly Dictionary _protoCollectionTypes = new()
+ {
+
+ """);
+
+ StringBuilder protoMemberGenerationMethod = new("""
+ ///
+ /// Convenience method to generate a Protobuf serialized parameter.
+ ///
+ public static object ToProtobufParameter(this object value, Type serializableType, bool isServer)
+ {
+ MemoryStream memoryStream = new();
+ switch (serializableType.Name)
+ {
+
+ """);
+
+ StringBuilder protoCollectionGenerationMethod = new("""
+ ///
+ /// Convenience method to generate a Protobuf serialized collection parameter.
+ ///
+ public static object ToProtobufCollectionParameter(this IList items, Type serializableType, bool isServer)
+ {
+ MemoryStream memoryStream = new();
+ string typeName = $"{serializableType.Name}Collection";
+ switch (serializableType.Name)
+ {
+
+ """);
+
+ foreach (KeyValuePair kvp in protoDefinitions.OrderBy(kvp => kvp.Key))
+ {
+ var protoSerializableType = kvp.Key;
+ var definition = kvp.Value;
+
+ if (definition.Name == "MapComponent")
+ {
+ continue;
+ }
+
+ var serializationRecordType = definition.Name + "SerializationRecord";
+ var serializationCollectionRecordType = definition.Name + "CollectionSerializationRecord";
+
+ var variableName = protoSerializableType.ToLowerFirstChar();
+
+ protoTypeDictionary.AppendLine($$"""
+ { typeof({{protoSerializableType}}), typeof({{
+ serializationRecordType}}) },
+ """);
+
+ protoCollectionTypeDictionary.AppendLine($$"""
+ { typeof({{protoSerializableType}}), typeof({{
+ serializationCollectionRecordType}}) },
+ """);
+
+ readJsProtoStreamRefMethod.AppendLine($$"""
+ case "{{protoSerializableType}}":
+ {{serializationRecordType}} {{variableName}} =
+ Serializer.Deserialize<{{serializationRecordType
+ }}>(memoryStream);
+ if ({{variableName}}.IsNull)
+ {
+ return default!;
+ }
+ return (T?)(object?){{variableName
+ }}?.FromSerializationRecord();
+ """);
+
+ readJsProtoCollectionStreamRefMethod.AppendLine($$"""
+ case "{{protoSerializableType}}":
+ {{serializationCollectionRecordType}} {{
+ variableName}} =
+ Serializer.Deserialize<{{
+ serializationCollectionRecordType
+ }}>(memoryStream);
+ if ({{variableName}}.IsNull)
+ {
+ return default!;
+ }
+ return (T?)(object?){{variableName
+ }}
+ ?.Items?.Select(i => i.FromSerializationRecord()).Cast<{{
+ protoSerializableType}}>().ToArray();
+ """);
+
+ if (definition.Name == "Attribute")
+ {
+ // Attribute is not a GeoBlazor type so skip next injection
+ continue;
+ }
+
+ protoMemberGenerationMethod.AppendLine($$"""
+ case "{{protoSerializableType}}":
+ {{definition.Name}}SerializationRecord {{
+ variableName}} =
+ (({{protoSerializableType
+ }})value).ToProtobuf();
+ Serializer.Serialize(memoryStream, {{variableName
+ }});
+
+ break;
+ """);
+
+ protoCollectionGenerationMethod.AppendLine($$"""
+ case "{{protoSerializableType}}":
+ {{definition.Name
+ }}CollectionSerializationRecord {{variableName
+ }} =
+ new(items.Cast<{{protoSerializableType
+ }}
+ >().Select(i => i.ToProtobuf()).ToArray());
+ Serializer.Serialize(memoryStream, {{
+ variableName}});
+
+ break;
+ """);
+ }
+
+ protoTypeDictionary.AppendLine(" };");
+ protoCollectionTypeDictionary.AppendLine(" };");
+
+ readJsProtoStreamRefMethod.AppendLine("""
+ }
+
+ return default!;
+ }
+ """);
+
+ readJsProtoCollectionStreamRefMethod.AppendLine("""
+ }
+
+ return default!;
+ }
+ """);
+
+ protoMemberGenerationMethod.AppendLine("""
+ }
+
+ memoryStream.Seek(0, SeekOrigin.Begin);
+
+ if (isServer)
+ {
+ return new DotNetStreamReference(memoryStream);
+ }
+
+ byte[] data = memoryStream.ToArray();
+ memoryStream.Dispose();
+
+ return data;
+ }
+ """);
+
+ protoCollectionGenerationMethod.AppendLine("""
+ }
+
+ memoryStream.Seek(0, SeekOrigin.Begin);
+
+ if (isServer)
+ {
+ return new DotNetStreamReference(memoryStream);
+ }
+
+ byte[] data = memoryStream.ToArray();
+ memoryStream.Dispose();
+
+ return data;
+ }
+ """);
+
+ return $"""
+ {readJsProtoStreamRefMethod}
+
+ {readJsProtoCollectionStreamRefMethod}
+
+ {protoMemberGenerationMethod}
+
+ {protoCollectionGenerationMethod}
+
+ {protoTypeDictionary}
+
+ {protoCollectionTypeDictionary}
+ """;
+ }
+
+ private static string GenerateSerializableMethodRecords(
+ ImmutableArray serializedMethodsCollection, bool isPro)
+ {
+ StringBuilder outputBuilder;
+
+ if (isPro)
+ {
+ outputBuilder = new("""
+ ///
+ /// A collection of serializable methods and their parameters/return types.
+ ///
+ public static Dictionary SerializableMethods => _serializableMethods
+ .Concat(CoreSerializationData.SerializableMethods)
+ .ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
+
+ private static readonly Dictionary _serializableMethods = new()
+ {
+
+ """);
+ }
+ else
+ {
+ outputBuilder = new("""
+ ///
+ /// A collection of serializable methods and their parameters/return types.
+ ///
+ public static Dictionary SerializableMethods => _serializableMethods;
+
+ private static readonly Dictionary _serializableMethods = new()
+ {
+
+ """);
+ }
+
+ foreach (var classGroup in serializedMethodsCollection.GroupBy(m => m.ClassName))
+ {
+ outputBuilder.AppendLine($$"""
+ ["{{classGroup.Key}}"] =
+ [
+ """);
+
+ foreach (var methodRecord in classGroup)
+ {
+ if (methodRecord.Parameters.Values.Contains("T")
+ || methodRecord.Parameters.Values.Contains("T?")
+ || (methodRecord.ReturnType == "T")
+ || (methodRecord.ReturnType == "T?")
+ || (methodRecord.ReturnType == "T[]")
+ || (methodRecord.ReturnType == "T[]?")
+ || (methodRecord.ReturnType?.Contains("") == true))
+ {
+ continue;
+ }
+
+ outputBuilder.AppendLine($$"""
+ new SerializableMethodRecord("{{
+ methodRecord.MethodName.ToLowerFirstChar()}}",
+ [
+ """);
+
+ foreach (var param in methodRecord.Parameters)
+ {
+ bool isNullable = param.Value.EndsWith("?");
+ string value = isNullable ? param.Value.TrimEnd('?') : param.Value;
+ string isNullableText = isNullable ? "true" : "false";
+ string? collectionType = null;
+
+ if (value.EndsWith("[]"))
+ {
+ collectionType = value.Replace("[]", "");
+ }
+ else if (value.Contains("<") && value.Contains(">"))
+ {
+ int genericStart = value.IndexOf("<", StringComparison.OrdinalIgnoreCase);
+ collectionType = value.Substring(genericStart + 1, value.Length - genericStart - 2);
+ }
+
+ string collectionText = collectionType is null
+ ? "null"
+ : $"typeof({collectionType})";
+
+ outputBuilder.AppendLine($" new SerializableParameterRecord(typeof({value
+ }), {isNullableText}, {collectionText}),");
+ }
+
+ if (methodRecord.ReturnType != null)
+ {
+ string returnValue = methodRecord.ReturnType.TrimEnd('?');
+
+ bool isCollectionReturn = returnValue.EndsWith("[]") ||
+ (returnValue.Contains("<") && returnValue.Contains(">"));
+
+ string singleType = isCollectionReturn
+ ? returnValue.Contains("<") && returnValue.Contains(">")
+ ? $"typeof({returnValue.Substring(
+ returnValue.IndexOf("<", StringComparison.OrdinalIgnoreCase) + 1,
+ returnValue.Length - returnValue.IndexOf("<", StringComparison.OrdinalIgnoreCase) - 2)
+ })"
+ : $"typeof({returnValue.Replace("[]", "")})"
+ : "null";
+ string isNullable = methodRecord.ReturnType.EndsWith("?") ? "true" : "false";
+
+ outputBuilder.AppendLine($" ], new SerializableParameterRecord(typeof({
+ returnValue}), {isNullable}, {singleType})),");
+ }
+ else
+ {
+ outputBuilder.AppendLine(" ])),");
+ }
+ }
+
+ outputBuilder.AppendLine(" ],");
+ }
+
+ outputBuilder.AppendLine(" };");
+
+ return outputBuilder.ToString();
+ }
+
+ private static List ToSerializableMethodRecords(this BaseTypeDeclarationSyntax typeSyntax)
+ {
+ List methods = typeSyntax
+ .ChildNodes()
+ .OfType()
+ .Where(m => m.AttributeLists
+ .SelectMany(a => a.Attributes)
+ .Any(attr => attr.Name.ToString() == SerializedMethodAttributeName))
+ .ToList();
+
+ List methodRecords = [];
+
+ foreach (MethodDeclarationSyntax method in methods)
+ {
+ string returnType = method.ReturnType.ToString();
+
+ if (returnType.StartsWith("Task") || returnType.StartsWith("ValueTask"))
+ {
+ int bracketIndex = returnType.IndexOf('<');
+ returnType = returnType.Substring(bracketIndex + 1, returnType.Length - bracketIndex - 2);
+ }
+
+ SerializableMethodRecord record = new(typeSyntax.Identifier.Text,
+ method.Identifier.Text,
+ method.ParameterList.Parameters.ToDictionary(p => p.Identifier.Text,
+ p => p.Type!.ToString()),
+ returnType);
+
+ methodRecords.Add(record);
+ }
+
+ return methodRecords;
+ }
+
+ private const string SerializedMethodAttributeName = "SerializedMethod";
+ private const string ProtoSerializableAttribute = "ProtobufSerializable";
+}
+
+///
+/// Represents metadata about a method marked for serialization, including its class, name, parameters, and return
+/// type.
+///
+/// The name of the class containing the method.
+/// The name of the method.
+/// A dictionary mapping parameter names to their type names.
+/// The return type of the method, or null if void.
+public record SerializableMethodRecord(
+ string ClassName,
+ string MethodName,
+ Dictionary Parameters,
+ string ReturnType)
+{
+ ///
+ /// The name of the class containing the method.
+ ///
+ public string ClassName { get; } = ClassName;
+
+ ///
+ /// The name of the method.
+ ///
+ public string MethodName { get; } = MethodName;
+
+ ///
+ /// A dictionary mapping parameter names to their type names.
+ ///
+ public Dictionary Parameters { get; } = Parameters;
+
+ ///
+ /// The return type of the method, or null if void.
+ ///
+ public string? ReturnType { get; } = ReturnType;
+}
\ No newline at end of file
diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/StringExtensions.cs b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/StringExtensions.cs
new file mode 100644
index 000000000..67b002473
--- /dev/null
+++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/StringExtensions.cs
@@ -0,0 +1,25 @@
+namespace dymaptic.GeoBlazor.Core.SourceGenerator.Shared;
+
+///
+/// Provides extension methods for string manipulation in the source generator.
+///
+public static class StringExtensions
+{
+ ///
+ /// Converts the first character of a string to lowercase.
+ ///
+ /// The input string to convert.
+ ///
+ /// The input string with its first character converted to lowercase,
+ /// or the original string if it is null, empty, or already starts with a lowercase character.
+ ///
+ public static string ToLowerFirstChar(this string input)
+ {
+ if (string.IsNullOrEmpty(input) || char.IsLower(input[0]))
+ {
+ return input;
+ }
+
+ return char.ToLower(input[0]) + input.Substring(1);
+ }
+}
\ No newline at end of file
diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/dymaptic.GeoBlazor.Core.SourceGenerator.Shared.csproj b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/dymaptic.GeoBlazor.Core.SourceGenerator.Shared.csproj
new file mode 100644
index 000000000..3737db384
--- /dev/null
+++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator.Shared/dymaptic.GeoBlazor.Core.SourceGenerator.Shared.csproj
@@ -0,0 +1,17 @@
+
+
+
+ Library
+ netstandard2.0
+ enable
+ enable
+ latest
+ Debug;Release;SourceGen Highlighting
+ AnyCPU
+
+
+
+
+
+
+
diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator/ArcGISVersionInfoSourceGenerator.cs b/src/dymaptic.GeoBlazor.Core.SourceGenerator/ArcGISVersionInfoSourceGenerator.cs
index ee6ce2419..f15f5b525 100644
--- a/src/dymaptic.GeoBlazor.Core.SourceGenerator/ArcGISVersionInfoSourceGenerator.cs
+++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator/ArcGISVersionInfoSourceGenerator.cs
@@ -1,15 +1,16 @@
-using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
+using System.Collections.Immutable;
namespace dymaptic.GeoBlazor.Core.SourceGenerator;
///
-/// Copies the JavaScript NPM Package Versions from the package.json file into the C# source code.
+/// Copies the JavaScript NPM Package Versions from the package.json file into the C# source code.
///
[Generator]
public class ArcGISVersionInfoSourceGenerator : IIncrementalGenerator
{
+ ///
public void Initialize(IncrementalGeneratorInitializationContext context)
{
IncrementalValueProvider> provider = context.AdditionalTextsProvider
@@ -22,7 +23,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
private void GenerateCode(SourceProductionContext context, ImmutableArray files)
{
AdditionalText? file = files.FirstOrDefault();
-
+
// Get the text of the file.
string[]? lines = file?.GetText(context.CancellationToken)?.ToString().Split('\n');
@@ -30,7 +31,7 @@ private void GenerateCode(SourceProductionContext context, ImmutableArray
+/// Triggers the ESBuild build process for the GeoBlazor project, so that your JavaScript code is up to date.
+///
+[Generator]
+[SuppressMessage("MicrosoftCodeAnalysisCorrectness", "RS1035:Do not use APIs banned for analyzers")]
+public class ESBuildGenerator : IIncrementalGenerator
+{
+ ///
+ /// Gets a value indicating whether an ESBuild process is currently running.
+ ///
+ public static bool InProcess { get; private set; }
+
+ ///
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ // Tracks all TypeScript source files in the Scripts directories of Core and Pro.
+ // This will trigger the build any time a TypeScript file is added, removed, or changed.
+ IncrementalValueProvider> tsFilesProvider = context.AdditionalTextsProvider
+ .Where(static text => text.Path.Contains("Scripts")
+ && text.Path.EndsWith(".ts"))
+ .Collect();
+
+ // Reads the MSBuild properties to get the project directory and configuration.
+ IncrementalValueProvider<(string?, string?, string?)> optionsProvider =
+ context.AnalyzerConfigOptionsProvider.Select((configProvider, _) =>
+ {
+ configProvider.GlobalOptions.TryGetValue("build_property.CoreProjectPath",
+ out string? projectDirectory);
+
+ configProvider.GlobalOptions.TryGetValue("build_property.Configuration",
+ out string? configuration);
+
+ configProvider.GlobalOptions.TryGetValue("build_property.PipelineBuild",
+ out string? pipelineBuild);
+
+ return (projectDirectory, configuration, pipelineBuild);
+ });
+
+ IncrementalValueProvider<((ImmutableArray Left, (string?, string?, string?) Right) Left,
+ Compilation Right)> combined =
+ tsFilesProvider
+ .Combine(optionsProvider)
+ .Combine(context.CompilationProvider);
+
+ context.RegisterSourceOutput(combined, FilesChanged);
+ }
+
+ private void FilesChanged(SourceProductionContext context,
+ ((ImmutableArray Files,
+ (string? ProjectDirectory, string? Configuration, string? PipelineBuild) Options) Data,
+ Compilation Compilation) pipeline)
+ {
+ if (!SetProjectDirectoryAndConfiguration(pipeline.Data.Options, context))
+ {
+ return;
+ }
+
+ ProcessHelper.Log(nameof(ESBuildGenerator),
+ "ESBuild Source Generation triggered.",
+ DiagnosticSeverity.Info,
+ context);
+
+ if (pipeline.Data.Options.PipelineBuild == "true")
+ {
+ // If the pipeline build is enabled, we skip the ESBuild process.
+ // This is to avoid race conditions where the files are not ready on time, and we do the build separately.
+ ProcessHelper.Log(nameof(ESBuildGenerator),
+ "Skipping ESBuild process as PipelineBuild is set to true.",
+ DiagnosticSeverity.Info,
+ context);
+
+ return;
+ }
+
+ if (pipeline.Data.Files.Length > 0)
+ {
+ LaunchESBuild(context);
+ }
+ }
+
+ private bool SetProjectDirectoryAndConfiguration(
+ (string? ProjectDirectory, string? Configuration, string? _) options,
+ SourceProductionContext context)
+ {
+ string? projectDirectory = options.ProjectDirectory;
+
+ if (projectDirectory is not null)
+ {
+ _corePath = Path.GetFullPath(projectDirectory);
+
+ ProcessHelper.Log(nameof(ESBuildGenerator),
+ $"Project directory set to {_corePath}",
+ DiagnosticSeverity.Info,
+ context);
+
+ if (_corePath.Contains("GeoBlazor.Pro"))
+ {
+ // we are inside the Pro submodule, we should also set the Pro path to build the Pro JavaScript files
+ string path = _corePath;
+
+ while (!path.EndsWith("GeoBlazor.Pro"))
+ {
+ // move up the directory tree until we find the GeoBlazor.Pro directory
+ path = Path.GetDirectoryName(path)!;
+ }
+
+ // set the pro path to the src/dymaptic.GeoBlazor.Pro directory
+ _proPath = Path.GetFullPath(Path.Combine(path, "src", "dymaptic.GeoBlazor.Pro"));
+ }
+ }
+ else
+ {
+ ProcessHelper.Log(nameof(ESBuildGenerator),
+ "Invalid project directory.",
+ DiagnosticSeverity.Error,
+ context);
+
+ return false;
+ }
+
+ if (options.Configuration is { } configuration)
+ {
+ _configuration = configuration;
+
+ return true;
+ }
+
+ ProcessHelper.Log(nameof(ESBuildGenerator),
+ "Could not parse configuration setting, invalid configuration.",
+ DiagnosticSeverity.Error,
+ context);
+
+ return false;
+ }
+
+ private void LaunchESBuild(SourceProductionContext context)
+ {
+ context.CancellationToken.ThrowIfCancellationRequested();
+
+ ProcessHelper.Log(nameof(ESBuildGenerator),
+ "Starting Core ESBuild process...",
+ DiagnosticSeverity.Info,
+ context);
+
+ StringBuilder logBuilder = new StringBuilder(DateTime.Now.ToLongTimeString());
+ logBuilder.AppendLine("Starting Core ESBuild process...");
+
+ try
+ {
+ List tasks = [];
+ bool buildSuccess = false;
+ bool proBuildSuccess = false;
+
+ // gets the esBuild.ps1 script from the Core path
+ tasks.Add(Task.Run(async () =>
+ {
+ await ProcessHelper.RunPowerShellScript("Core",
+ _corePath!, "esBuild.ps1",
+ $"-c {_configuration}", logBuilder, context.CancellationToken);
+ buildSuccess = true;
+ }));
+
+ if (_proPath is not null)
+ {
+ logBuilder.AppendLine("Starting Pro ESBuild process...");
+
+ tasks.Add(Task.Run(async () =>
+ {
+ await ProcessHelper.RunPowerShellScript("Pro",
+ _proPath, "esProBuild.ps1",
+ $"-c {_configuration}", logBuilder, context.CancellationToken);
+ proBuildSuccess = true;
+ }));
+ }
+
+ Task.WhenAll(tasks).GetAwaiter().GetResult();
+
+ if (!buildSuccess)
+ {
+ ProcessHelper.Log(nameof(ESBuildGenerator),
+ $"Core ESBuild process failed\r\n{logBuilder}",
+ DiagnosticSeverity.Error,
+ context);
+
+ return;
+ }
+
+ logBuilder.AppendLine("Core ESBuild process completed successfully.");
+ logBuilder.AppendLine();
+
+ if (_proPath is not null)
+ {
+ if (!proBuildSuccess)
+ {
+ ProcessHelper.Log(nameof(ESBuildGenerator),
+ $"Pro ESBuild process failed\r\n{logBuilder}",
+ DiagnosticSeverity.Error,
+ context);
+
+ return;
+ }
+
+ logBuilder.AppendLine("Pro ESBuild process completed successfully.");
+ logBuilder.AppendLine();
+ }
+
+ ProcessHelper.Log(nameof(ESBuildGenerator),
+ logBuilder.ToString(),
+ DiagnosticSeverity.Info,
+ context);
+ }
+ catch (Exception ex)
+ {
+ ProcessHelper.Log(nameof(ESBuildGenerator),
+ $"An error occurred while running ESBuild: {ex.Message}\r\n{ex.StackTrace}",
+ DiagnosticSeverity.Error,
+ context);
+
+ ClearESBuildLocks(context);
+ }
+ }
+
+ private void ClearESBuildLocks(SourceProductionContext context)
+ {
+ StringBuilder logBuilder = new();
+ string rootCorePath = Path.Combine(_corePath!, "..", "..");
+
+ _ = Task.Run(async () => await ProcessHelper.RunPowerShellScript("Clear Locks",
+ rootCorePath, "esBuildClearLocks.ps1", "",
+ logBuilder, context.CancellationToken));
+
+ ProcessHelper.Log(nameof(ESBuildGenerator),
+ "Cleared ESBuild Process Locks",
+ DiagnosticSeverity.Info,
+ context);
+ }
+
+ private static string? _corePath;
+ private static string? _proPath;
+ private static string? _configuration;
+}
\ No newline at end of file
diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator/ESBuildLauncher.cs b/src/dymaptic.GeoBlazor.Core.SourceGenerator/ESBuildLauncher.cs
deleted file mode 100644
index e111f8c3f..000000000
--- a/src/dymaptic.GeoBlazor.Core.SourceGenerator/ESBuildLauncher.cs
+++ /dev/null
@@ -1,327 +0,0 @@
-using Microsoft.CodeAnalysis;
-using System.Collections.Immutable;
-using System.Diagnostics;
-using System.Diagnostics.CodeAnalysis;
-using System.Text;
-using System.Text.RegularExpressions;
-
-
-namespace dymaptic.GeoBlazor.Core.SourceGenerator;
-
-///
-/// Triggers the ESBuild build process for the GeoBlazor project, so that your JavaScript code is up to date.
-///
-[Generator]
-[SuppressMessage("MicrosoftCodeAnalysisCorrectness", "RS1035:Do not use APIs banned for analyzers")]
-public class ESBuildLauncher : IIncrementalGenerator
-{
- // Notifications are only used for the unit tests, source generators are not intended to have logging/output typically.
- public event EventHandler? Notification;
-
- public void Initialize(IncrementalGeneratorInitializationContext context)
- {
- // Tracks all TypeScript source files in the Scripts directories of Core and Pro.
- // This will trigger the build any time a TypeScript file is added, removed, or changed.
- IncrementalValueProvider> jsFilesProvider = context.AdditionalTextsProvider
- .Where(static text => text.Path.Contains("Scripts") && text.Path.EndsWith(".ts"))
- .Collect();
-
- // Reads the MSBuild properties to get the project directory and configuration.
- IncrementalValueProvider<(string?, string?, string?, string?)> optionsProvider =
- context.AnalyzerConfigOptionsProvider.Select((configProvider, _) =>
- {
- configProvider.GlobalOptions.TryGetValue("build_property.MSBuildProjectDirectory",
- out string? projectDirectory);
-
- configProvider.GlobalOptions.TryGetValue("build_property.Configuration",
- out string? configuration);
-
- configProvider.GlobalOptions.TryGetValue("build_property.PipelineBuild",
- out string? pipelineBuild);
-
- configProvider.GlobalOptions.TryGetValue("build_property.LogESBuildOutput",
- out string? logESBuildOutput);
-
- return (projectDirectory, configuration, pipelineBuild, logESBuildOutput);
- });
-
- context.RegisterSourceOutput(optionsProvider.Combine(jsFilesProvider), FilesChanged);
- }
-
- private void FilesChanged(SourceProductionContext context,
- ((string? projectDirectory, string? configuration, string? pipelineBuild, string? logESBuildOutput) OptionsConfig,
- ImmutableArray _) pipeline)
- {
- _logESBuildOutput = pipeline.OptionsConfig.logESBuildOutput == "true";
-
- if (pipeline.OptionsConfig.pipelineBuild == "true")
- {
- // If the pipeline build is enabled, we skip the ESBuild process.
- // This is to avoid race conditions where the files are not ready on time, and we do the build separately.
- Notification?.Invoke(this, "Skipping ESBuild process as PipelineBuild is set to true.");
- Log("Skipping ESBuild process as PipelineBuild is set to true.");
- return;
- }
-
- SetProjectDirectoryAndConfiguration(pipeline.OptionsConfig);
- Log("ESBuildLauncher triggered.");
- LaunchESBuild(context);
- }
-
- private void SetProjectDirectoryAndConfiguration((string? projectDirectory, string? configuration,
- string? _, string? __) options)
- {
- if (options.projectDirectory is { } projectDirectory)
- {
- _corePath = Path.GetFullPath(projectDirectory);
-
- if (_corePath.Contains("GeoBlazor.Pro"))
- {
- // we are inside the Pro submodule, we should also set the Pro path to build the Pro JavaScript files
- string path = _corePath;
-
- while (!path.EndsWith("GeoBlazor.Pro"))
- {
- // move up the directory tree until we find the GeoBlazor.Pro directory
- path = Path.GetDirectoryName(path)!;
- }
-
- // set the pro path to the src/dymaptic.GeoBlazor.Pro directory
- _proPath = Path.GetFullPath(Path.Combine(path, "src", "dymaptic.GeoBlazor.Pro"));
- }
- }
- else
- {
- throw new Exception("Invalid project directory");
- }
-
- if (options.configuration is { } configuration)
- {
- _configuration = configuration;
- }
- else
- {
- Log("Could not parse configuration setting", true);
- throw new Exception("Invalid configuration");
- }
- }
-
- private void LaunchESBuild(SourceProductionContext context)
- {
- context.CancellationToken.ThrowIfCancellationRequested();
- Notification?.Invoke(this, "Starting Core ESBuild process...");
-
- StringBuilder logBuilder = new StringBuilder(DateTime.Now.ToLongTimeString());
- logBuilder.AppendLine("Starting Core ESBuild process...");
-
- try
- {
- List tasks = [];
- bool buildSuccess = false;
- bool proBuildSuccess = false;
-
- // gets the esBuild.ps1 script from the Core path
- tasks.Add(Task.Run(async () =>
- buildSuccess = await RunPowerShellScript("Core", "esBuild.ps1", _corePath!,
- $"-c {_configuration}", logBuilder, context.CancellationToken)));
-
- if (_proPath is not null)
- {
- Notification?.Invoke(this, "Starting Pro ESBuild process...");
- logBuilder.AppendLine("Starting Pro ESBuild process...");
-
- tasks.Add(Task.Run(async () =>
- proBuildSuccess = await RunPowerShellScript("Pro", "esProBuild.ps1", _proPath,
- $"-c {_configuration}", logBuilder, context.CancellationToken)));
- }
-
- string gitBranch = string.Empty;
-
- Process gitBranchProc = Process.Start(new ProcessStartInfo
- {
- WorkingDirectory = _corePath!,
- FileName = "git",
- Arguments = "rev-parse --abbrev-ref HEAD",
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- UseShellExecute = false,
- CreateNoWindow = true
- })!;
-
- tasks.Add(Task.Run(async () =>
- gitBranch = (await gitBranchProc.StandardOutput.ReadLineAsync())?.Trim() ?? string.Empty));
-
- Task.WhenAll(tasks).GetAwaiter().GetResult();
-
- if (!buildSuccess)
- {
- Notification?.Invoke(this, "Core ESBuild process failed");
-
- throw new Exception($"Core ESBuild process failed.\r\n{logBuilder}");
- }
-
- Notification?.Invoke(this, "Core ESBuild process completed successfully.");
- Notification?.Invoke(this, "");
- logBuilder.AppendLine("Core ESBuild process completed successfully.");
- logBuilder.AppendLine();
-
- if (_proPath is not null)
- {
- if (!proBuildSuccess)
- {
- Notification?.Invoke(this, "Pro ESBuild process failed");
-
- throw new Exception($"Pro ESBuild process failed.\r\n{logBuilder}");
- }
-
- Notification?.Invoke(this, "Pro ESBuild process completed successfully.");
- Notification?.Invoke(this, "");
- logBuilder.AppendLine("Pro ESBuild process completed successfully.");
- logBuilder.AppendLine();
- }
-
- if (string.IsNullOrEmpty(gitBranch))
- {
- Notification?.Invoke(this, "Could not determine the current Git branch. " +
- "This may affect the generated ESBuildRecord class.");
-
- logBuilder.AppendLine("Could not determine the current Git branch. " +
- "This may affect the generated ESBuildRecord class.");
- gitBranch = "unknown";
- }
-
- Notification?.Invoke(this,
- $"ESBuild completed successfully for branch '{gitBranch}' with configuration '{_configuration}'.");
-
- logBuilder.AppendLine($"ESBuild completed successfully for branch '{gitBranch}' with configuration '{
- _configuration}'.");
-
- string source = $$"""
- //
-
- namespace dymaptic.GeoBlazor.Core;
-
- ///
- /// This class is generated by a source generator and contains metadata about the build.
- ///
- internal class ESBuildRecord
- {
- private const long Timestamp = {{DateTime.UtcNow.Ticks}};
- private const string GitBranch = "{{gitBranch}}";
- private const string Configuration = "{{_configuration}}";
- private const bool IncludeProBuild = {{(_proPath is not null).ToString().ToLower()}};
- }
- """;
-
- context.AddSource("ESBuildRecord.g.cs", source);
- logBuilder.AppendLine();
- logBuilder.AppendLine(source);
- Log(logBuilder.ToString());
-
- Notification?.Invoke(this, "");
- Notification?.Invoke(this, source);
- }
- catch (Exception ex)
- {
- Notification?.Invoke(this, $"An error occurred while running ESBuild: {ex.Message}");
- Notification?.Invoke(this, ex.StackTrace);
-
- Log($"{ex.Message}\r\n{ex.StackTrace}", true);
-
- throw new Exception(
- $"An error occurred while running ESBuild: {ex.Message}\n\n{logBuilder}\n\n{ex.StackTrace}", ex);
- }
- }
-
- private void Log(string content, bool isError = false)
- {
- if (!_logESBuildOutput && !isError)
- {
- return;
- }
- StringBuilder loggerOutput = new StringBuilder();
- // Replace multiple consecutive newlines (with optional whitespace) with a single newline
- content = Regex.Replace(content, @"\r?\n(?:\s*\r?\n)+", "\n");
-
- if (!RunPowerShellScript("Logger", "esBuildLogger.ps1", _corePath!,
- $"-c \"{content}\" -e {isError.ToString().ToLower()}", loggerOutput,
- CancellationToken.None)
- .GetAwaiter()
- .GetResult())
- {
- throw new Exception($"Failed to run the ESBuild logger script. {loggerOutput}");
- }
- }
-
- private async Task RunPowerShellScript(string processName, string powershellScriptName, string workingFolder,
- string arguments, StringBuilder logBuilder, CancellationToken cancellationToken)
- {
- ProcessStartInfo processStartInfo = new()
- {
- WorkingDirectory = workingFolder,
- FileName = "pwsh",
- Arguments =
- $"-NoProfile -ExecutionPolicy ByPass -File \"{Path.Combine(workingFolder, powershellScriptName)}\" {arguments}",
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- UseShellExecute = false,
- CreateNoWindow = true
- };
-
- using var process = Process.Start(processStartInfo);
-
- if (process == null)
- {
- Notification?.Invoke(this, $"Failed to start ESBuild process for {processName}.");
- logBuilder.AppendLine($"Failed to start ESBuild process for {processName}.");
- return false;
- }
-
- // Read both streams concurrently to avoid deadlocks
- Task outputTask = ReadStreamAsync(process.StandardOutput, $"{processName} ESBuild Output", logBuilder, cancellationToken);
- Task errorTask = ReadStreamAsync(process.StandardError, $"{processName} ESBuild Error", logBuilder, cancellationToken);
-
- try
- {
- // Use Task.Run to make the blocking WaitForExit async-friendly
- await Task.Run(() => process.WaitForExit(), cancellationToken);
- await Task.WhenAll(outputTask, errorTask);
-
- return process.ExitCode == 0;
- }
- catch (OperationCanceledException)
- {
- process.Kill();
- return false;
- }
- }
-
- private async Task ReadStreamAsync(StreamReader reader, string prefix, StringBuilder logBuilder, CancellationToken cancellationToken)
- {
- try
- {
- while (!cancellationToken.IsCancellationRequested)
- {
- string? line = await reader.ReadLineAsync();
- if (line == null) break;
-
- if (!string.IsNullOrWhiteSpace(line))
- {
- Notification?.Invoke(this, $"{prefix}: {line}");
- logBuilder.AppendLine($"{prefix}: {line}");
- }
- }
- }
- catch when (cancellationToken.IsCancellationRequested)
- {
- // Expected when cancellation occurs
- Notification?.Invoke(this, $"{prefix}: Process was cancelled.");
- logBuilder.AppendLine($"{prefix}: Process was cancelled.");
- }
- }
-
- private static string? _corePath;
- private static string? _proPath;
- private static string? _configuration;
- private static bool _logESBuildOutput;
-}
\ No newline at end of file
diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator/ProtobufSourceGenerator.cs b/src/dymaptic.GeoBlazor.Core.SourceGenerator/ProtobufSourceGenerator.cs
new file mode 100644
index 000000000..3f5c9033d
--- /dev/null
+++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator/ProtobufSourceGenerator.cs
@@ -0,0 +1,92 @@
+using dymaptic.GeoBlazor.Core.SourceGenerator.Shared;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using System.Collections.Immutable;
+
+
+namespace dymaptic.GeoBlazor.Core.SourceGenerator;
+
+///
+/// Source generator for protobuf serialization records.
+/// This generator scans for types marked with ProtoContract or ProtobufSerializable attributes
+/// and generates serialization infrastructure.
+///
+[Generator]
+public class ProtobufSourceGenerator : IIncrementalGenerator
+{
+ ///
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ // Finds all class, struct, and record declarations marked with protobuf attributes.
+ IncrementalValueProvider> typeProvider =
+ context.SyntaxProvider.CreateSyntaxProvider(static (syntaxNode, _) =>
+ syntaxNode is ClassDeclarationSyntax or StructDeclarationSyntax or RecordDeclarationSyntax
+ && (((BaseTypeDeclarationSyntax)syntaxNode).AttributeLists.SelectMany(a => a.Attributes)
+ .Any(a => a.Name.ToString() is ProtoContractAttribute or ProtoSerializableAttribute)
+ || syntaxNode.ChildNodes()
+ .OfType()
+ .Any(m => m.AttributeLists
+ .SelectMany(a => a.Attributes)
+ .Any(attr => attr.Name.ToString() == SerializedMethodAttributeName))),
+ static (context, _) => (BaseTypeDeclarationSyntax)context.Node)
+ .Collect();
+
+ // Reads the MSBuild properties to get the project directory.
+ IncrementalValueProvider optionsProvider =
+ context.AnalyzerConfigOptionsProvider.Select((configProvider, _) =>
+ {
+ configProvider.GlobalOptions.TryGetValue("build_property.CoreProjectPath",
+ out var projectDirectory);
+
+ return projectDirectory;
+ });
+
+ var combined =
+ typeProvider.Combine(optionsProvider)
+ .Combine(context.CompilationProvider);
+
+ context.RegisterSourceOutput(combined, FilesChanged);
+ }
+
+ private void FilesChanged(SourceProductionContext context,
+ ((ImmutableArray Types, string? ProjectDirectory) Data,
+ Compilation Compilation) pipeline)
+ {
+ // Skip if not running from the Core project
+ if (pipeline.Compilation.AssemblyName != "dymaptic.GeoBlazor.Core")
+ {
+ return;
+ }
+
+ // Skip source generation if the project path is not available
+ if (string.IsNullOrEmpty(pipeline.Data.ProjectDirectory))
+ {
+ ProcessHelper.Log(nameof(ProtobufSourceGenerator),
+ "CoreProjectPath not set. Skipping protobuf source generation.",
+ DiagnosticSeverity.Warning,
+ context);
+
+ return;
+ }
+
+ // Log that protobuf types were found (infrastructure ready for future implementation)
+ if (pipeline.Data.Types.Length > 0)
+ {
+ ProcessHelper.Log(nameof(ProtobufSourceGenerator),
+ $"Found {pipeline.Data.Types.Length} protobuf-serializable types.",
+ DiagnosticSeverity.Info,
+ context);
+
+ // TODO: Generate protobuf serialization records and registration code.
+ // This will include:
+ // 1. Generating *SerializationRecord classes for each protobuf-attributed type
+ // 2. Generating ToProtobuf()/FromProtobuf() extension methods
+ // 3. Generating JsSyncManager registration code for ProtoContractTypes dictionary
+ // 4. Copying .proto definitions to TypeScript for JS-side deserialization
+ }
+ }
+
+ private const string ProtoContractAttribute = "ProtoContract";
+ private const string ProtoSerializableAttribute = "ProtobufSerializable";
+ private const string SerializedMethodAttributeName = "SerializedMethod";
+}
\ No newline at end of file
diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator/dymaptic.GeoBlazor.Core.SourceGenerator.csproj b/src/dymaptic.GeoBlazor.Core.SourceGenerator/dymaptic.GeoBlazor.Core.SourceGenerator.csproj
index 9ad32c278..f84022e51 100644
--- a/src/dymaptic.GeoBlazor.Core.SourceGenerator/dymaptic.GeoBlazor.Core.SourceGenerator.csproj
+++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator/dymaptic.GeoBlazor.Core.SourceGenerator.csproj
@@ -3,18 +3,37 @@
netstandard2.0
false
- enable
latest
-
true
true
-
dymaptic.GeoBlazor.Core.SourceGenerator
dymaptic.GeoBlazor.Core.SourceGenerator
-
+
+
+
+
+
+
+
+ $(GetTargetPathDependsOn);GetDependencyTargetPaths
+
+
+
+
+
+
+
+
+
+
diff --git a/src/dymaptic.GeoBlazor.Core/Attributes/ProtobufSerializableAttribute.cs b/src/dymaptic.GeoBlazor.Core/Attributes/ProtobufSerializableAttribute.cs
new file mode 100644
index 000000000..d22ded48b
--- /dev/null
+++ b/src/dymaptic.GeoBlazor.Core/Attributes/ProtobufSerializableAttribute.cs
@@ -0,0 +1,6 @@
+namespace dymaptic.GeoBlazor.Core.Attributes;
+
+///
+/// Marks a type as serializable by Protobuf. To be used in conjunction with the interface.
+///
+public class ProtobufSerializableAttribute: Attribute;
\ No newline at end of file
diff --git a/src/dymaptic.GeoBlazor.Core/Attributes/SerializedMethodAttribute.cs b/src/dymaptic.GeoBlazor.Core/Attributes/SerializedMethodAttribute.cs
new file mode 100644
index 000000000..e0f732f84
--- /dev/null
+++ b/src/dymaptic.GeoBlazor.Core/Attributes/SerializedMethodAttribute.cs
@@ -0,0 +1,6 @@
+namespace dymaptic.GeoBlazor.Core.Attributes;
+
+///
+/// Identifies a method that will be serialized by the GeoBlazor serialization system.
+///
+public class SerializedMethodAttribute: Attribute;
\ No newline at end of file
diff --git a/src/dymaptic.GeoBlazor.Core/Components/MapComponent.razor.cs b/src/dymaptic.GeoBlazor.Core/Components/MapComponent.razor.cs
index 58cf5094a..60725abe7 100644
--- a/src/dymaptic.GeoBlazor.Core/Components/MapComponent.razor.cs
+++ b/src/dymaptic.GeoBlazor.Core/Components/MapComponent.razor.cs
@@ -1989,6 +1989,4 @@ public override void Write(Utf8JsonWriter writer, MapComponent value, JsonSerial
writer.WriteRawValue(JsonSerializer.Serialize(value, typeof(object),
GeoBlazorSerialization.JsonSerializerOptions));
}
-}
-
-internal record MapComponentSerializationRecord;
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/src/dymaptic.GeoBlazor.Core/Interfaces/IProtobufSerializable.cs b/src/dymaptic.GeoBlazor.Core/Interfaces/IProtobufSerializable.cs
new file mode 100644
index 000000000..fc299ce40
--- /dev/null
+++ b/src/dymaptic.GeoBlazor.Core/Interfaces/IProtobufSerializable.cs
@@ -0,0 +1,18 @@
+using dymaptic.GeoBlazor.Core.Serialization;
+
+namespace dymaptic.GeoBlazor.Core.Interfaces;
+
+///
+/// Interface to indicate that a class can be serialized to and from Protobuf format.
+///
+/// The serialization record type.
+public interface IProtobufSerializable where T : MapComponentSerializationRecord, new()
+{
+ ///
+ /// Converts the class to its Protobuf serialization record.
+ ///
+ ///
+ /// Returns the serializable instance
+ ///
+ T ToProtobuf();
+}
diff --git a/src/dymaptic.GeoBlazor.Core/ReadMe.md b/src/dymaptic.GeoBlazor.Core/ReadMe.md
index 81485862c..57f162e61 100644
--- a/src/dymaptic.GeoBlazor.Core/ReadMe.md
+++ b/src/dymaptic.GeoBlazor.Core/ReadMe.md
@@ -10,6 +10,10 @@ GeoBlazor brings the power of the ArcGIS Maps SDK for JavaScript into your Blazo
[](https://github.com/dymaptic/GeoBlazor/issues)
[](https://github.com/dymaptic/GeoBlazor/pulls)
+
+
+
+
**CORE**
[](https://www.nuget.org/packages/dymaptic.GeoBlazor.Core/)
@@ -20,6 +24,8 @@ GeoBlazor brings the power of the ArcGIS Maps SDK for JavaScript into your Blazo
[](https://www.nuget.org/packages/dymaptic.GeoBlazor.Pro/)
[](https://www.nuget.org/stats/packages/dymaptic.GeoBlazor.Pro?groupby=Version)
+**COMMUNITY**
+
[](https://discord.gg/hcmbPzn4VW)
## ✨ Key Features
diff --git a/src/dymaptic.GeoBlazor.Core/Serialization/GeoBlazorMetaData.cs b/src/dymaptic.GeoBlazor.Core/Serialization/GeoBlazorMetaData.cs
new file mode 100644
index 000000000..0852aabd4
--- /dev/null
+++ b/src/dymaptic.GeoBlazor.Core/Serialization/GeoBlazorMetaData.cs
@@ -0,0 +1,36 @@
+namespace dymaptic.GeoBlazor.Core.Serialization;
+
+///
+/// Provides metadata about GeoBlazor types for serialization and reflection purposes.
+///
+public static class GeoBlazorMetaData
+{
+ ///
+ /// All types from GeoBlazor.Core and GeoBlazor.Pro (if available) assemblies.
+ ///
+ public static Type[] GeoblazorTypes
+ {
+ get
+ {
+ if (field == null)
+ {
+ field = typeof(GeoBlazorMetaData).Assembly.GetTypes();
+
+ try
+ {
+ // Attempt to load GeoBlazor.Pro types if the assembly is available.
+ // This enables protobuf serialization to work with both Core and Pro types.
+ var proAssembly = Assembly.Load("dymaptic.GeoBlazor.Pro");
+ var proTypes = proAssembly.GetTypes();
+ field = field.Concat(proTypes).ToArray();
+ }
+ catch (FileNotFoundException)
+ {
+ // GeoBlazor.Pro assembly not available - this is expected for Core-only installations
+ }
+ }
+
+ return field;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/dymaptic.GeoBlazor.Core/Serialization/JsSyncManager.cs b/src/dymaptic.GeoBlazor.Core/Serialization/JsSyncManager.cs
new file mode 100644
index 000000000..b7856f288
--- /dev/null
+++ b/src/dymaptic.GeoBlazor.Core/Serialization/JsSyncManager.cs
@@ -0,0 +1,95 @@
+using Microsoft.JSInterop;
+using ProtoBuf.Meta;
+using System.Runtime.CompilerServices;
+
+namespace dymaptic.GeoBlazor.Core.Serialization;
+
+///
+/// Manages JavaScript interop with support for Protobuf serialization and streaming.
+///
+///
+/// This is the infrastructure class for the Protobuf serialization system.
+/// The full implementation with serialization records will be added in a subsequent PR.
+///
+public static class JsSyncManager
+{
+ ///
+ /// Dictionary of serializable methods keyed by class name.
+ ///
+ public static Dictionary SerializableMethods { get; set; } = [];
+
+ ///
+ /// Dictionary mapping source types to their Protobuf contract types.
+ ///
+ public static Dictionary ProtoContractTypes { get; set; } = [];
+
+ ///
+ /// Dictionary mapping collection item types to their Protobuf collection types.
+ ///
+ public static Dictionary ProtoCollectionTypes { get; set; } = [];
+
+ private static Dictionary>? _serializableMethods;
+
+ ///
+ /// Initializes the JsSyncManager with Protobuf type registrations.
+ ///
+ ///
+ /// This method should be called once at application startup to register all
+ /// protobuf types with the RuntimeTypeModel and compile the serialization model.
+ ///
+ public static void Initialize()
+ {
+ // TODO: ProtoContractTypes and SerializableMethods dictionaries will be populated
+ // by generated code from ProtobufSourceGenerator in a subsequent PR.
+ foreach (Type protoType in ProtoContractTypes.Values)
+ {
+ RuntimeTypeModel.Default.Add(protoType, true);
+ }
+
+ RuntimeTypeModel.Default.CompileInPlace();
+
+ _serializableMethods = SerializableMethods
+ .ToDictionary(g => g.Key, g => g.Value.ToList());
+ }
+
+ ///
+ /// Wrapper method to invoke a void JS function with serialization support.
+ ///
+ /// The IJSObjectReference to invoke the method on.
+ /// Boolean flag to identify if GeoBlazor is running in Blazor Server mode.
+ /// The name of the JS function to call.
+ /// The name of the calling class.
+ /// A CancellationToken to cancel an asynchronous operation.
+ /// The collection of parameters to pass to the JS call.
+ public static async Task InvokeVoidJsMethod(this IJSObjectReference js, bool isServer,
+ [CallerMemberName] string method = "", string className = "",
+ CancellationToken cancellationToken = default, params object?[] parameters)
+ {
+ // TODO: Implement protobuf serialization for Blazor Server mode.
+ // When isServer is true and the method/parameters support protobuf serialization,
+ // this should serialize parameters to binary format for more efficient transfer.
+ await js.InvokeVoidAsync(method, cancellationToken, parameters);
+ }
+
+ ///
+ /// Wrapper method to invoke a JS function that returns a value with serialization support.
+ ///
+ /// The expected return type.
+ /// The IJSObjectReference to invoke the method on.
+ /// Boolean flag to identify if GeoBlazor is running in Blazor Server mode.
+ /// The name of the JS function to call.
+ /// The name of the calling class.
+ /// A CancellationToken to cancel an asynchronous operation.
+ /// The collection of parameters to pass to the JS call.
+ /// The result of the JS call.
+ public static async Task InvokeJsMethod(this IJSObjectReference js, bool isServer,
+ [CallerMemberName] string method = "", string className = "",
+ CancellationToken cancellationToken = default,
+ params object?[] parameters)
+ {
+ // TODO: Implement protobuf deserialization for Blazor Server mode.
+ // When isServer is true and the return type supports protobuf serialization,
+ // this should deserialize the binary response for more efficient transfer.
+ return await js.InvokeAsync(method, cancellationToken, parameters);
+ }
+}
diff --git a/src/dymaptic.GeoBlazor.Core/Serialization/ProtobufSerializationBase.cs b/src/dymaptic.GeoBlazor.Core/Serialization/ProtobufSerializationBase.cs
new file mode 100644
index 000000000..bbc6528be
--- /dev/null
+++ b/src/dymaptic.GeoBlazor.Core/Serialization/ProtobufSerializationBase.cs
@@ -0,0 +1,77 @@
+using ProtoBuf;
+
+namespace dymaptic.GeoBlazor.Core.Serialization;
+
+///
+/// Base class for all Protobuf serialization records for MapComponents.
+///
+[ProtoContract(Name = "MapComponent")]
+public record MapComponentSerializationRecord
+{
+ // ProtoMember tag 1000 is used for base class properties to avoid conflicts with derived class tags.
+ // Derived classes use tags 1-999 for their specific properties.
+
+ ///
+ /// Indicates whether this record represents a null value.
+ ///
+ [ProtoMember(1000)]
+ public virtual bool IsNull { get; init; }
+}
+
+///
+/// Generic base class for Protobuf serialization records that can convert back to their source type.
+///
+/// The type that this record serializes.
+public abstract record MapComponentSerializationRecord : MapComponentSerializationRecord
+{
+ ///
+ /// Converts this serialization record back to its source type.
+ ///
+ public abstract T? FromSerializationRecord();
+}
+
+///
+/// Base class for Protobuf serialization records representing collections.
+///
+[ProtoContract(Name = "MapComponentCollection")]
+public record MapComponentBaseCollectionSerializationRecord
+{
+ // ProtoMember tag 1000 is used for base class properties to avoid conflicts with derived class tags.
+
+ ///
+ /// Indicates whether this record represents a null collection.
+ ///
+ [ProtoMember(1000)]
+ public virtual bool IsNull { get; init; }
+}
+
+///
+/// Generic base class for Protobuf serialization records representing collections.
+///
+/// The type of items in the collection.
+public abstract record MapComponentCollectionSerializationRecord : MapComponentBaseCollectionSerializationRecord
+ where TItem : MapComponentSerializationRecord
+{
+ ///
+ /// The items in the collection.
+ ///
+ public abstract TItem[]? Items { get; set; }
+}
+
+///
+/// Record representing serializable method metadata for JS interop.
+///
+public record SerializableMethodRecord(
+ string MethodName,
+ SerializableParameterRecord[]? Parameters,
+ SerializableParameterRecord? ReturnValue);
+
+///
+/// Record representing a serializable parameter for JS interop.
+///
+public record SerializableParameterRecord(
+ string Name,
+ Type Type,
+ bool IsNullable,
+ bool IsCollection,
+ Type? CollectionItemType);
diff --git a/src/dymaptic.GeoBlazor.Core/Serialization/SerializationExtensions.cs b/src/dymaptic.GeoBlazor.Core/Serialization/SerializationExtensions.cs
new file mode 100644
index 000000000..036410cdb
--- /dev/null
+++ b/src/dymaptic.GeoBlazor.Core/Serialization/SerializationExtensions.cs
@@ -0,0 +1,14 @@
+namespace dymaptic.GeoBlazor.Core.Serialization;
+
+///
+/// Extension methods for protobuf serialization support.
+///
+///
+/// This partial class will contain generated extension methods for serializing
+/// MapComponent types to their protobuf representations.
+///
+internal static partial class SerializationExtensions
+{
+ // TODO: Extension methods will be generated by ProtobufSourceGenerator in a subsequent PR.
+ // These methods will provide ToProtobuf() and FromProtobuf() conversions for MapComponent types.
+}
\ No newline at end of file
diff --git a/src/dymaptic.GeoBlazor.Core/badge_fullmethodcoverage.svg b/src/dymaptic.GeoBlazor.Core/badge_fullmethodcoverage.svg
index e69de29bb..d93f43e4d 100644
--- a/src/dymaptic.GeoBlazor.Core/badge_fullmethodcoverage.svg
+++ b/src/dymaptic.GeoBlazor.Core/badge_fullmethodcoverage.svg
@@ -0,0 +1,151 @@
+
\ No newline at end of file
diff --git a/src/dymaptic.GeoBlazor.Core/badge_linecoverage.svg b/src/dymaptic.GeoBlazor.Core/badge_linecoverage.svg
index e69de29bb..76f9ab5d1 100644
--- a/src/dymaptic.GeoBlazor.Core/badge_linecoverage.svg
+++ b/src/dymaptic.GeoBlazor.Core/badge_linecoverage.svg
@@ -0,0 +1,144 @@
+
\ No newline at end of file
diff --git a/src/dymaptic.GeoBlazor.Core/badge_methodcoverage.svg b/src/dymaptic.GeoBlazor.Core/badge_methodcoverage.svg
index e69de29bb..73fde5e8e 100644
--- a/src/dymaptic.GeoBlazor.Core/badge_methodcoverage.svg
+++ b/src/dymaptic.GeoBlazor.Core/badge_methodcoverage.svg
@@ -0,0 +1,148 @@
+
\ No newline at end of file
diff --git a/src/dymaptic.GeoBlazor.Core/dymaptic.GeoBlazor.Core.csproj b/src/dymaptic.GeoBlazor.Core/dymaptic.GeoBlazor.Core.csproj
index 3b56a3245..ba582181a 100644
--- a/src/dymaptic.GeoBlazor.Core/dymaptic.GeoBlazor.Core.csproj
+++ b/src/dymaptic.GeoBlazor.Core/dymaptic.GeoBlazor.Core.csproj
@@ -77,9 +77,6 @@
-
-
-
diff --git a/test/Directory.Build.props b/test/Directory.Build.props
new file mode 100644
index 000000000..438ec4d30
--- /dev/null
+++ b/test/Directory.Build.props
@@ -0,0 +1,11 @@
+
+
+
+
+
+ false
+ false
+ false
+ false
+
+
\ No newline at end of file
diff --git a/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/CoreSourceGeneratorTests.cs b/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/CoreSourceGeneratorTests.cs
new file mode 100644
index 000000000..31c35b516
--- /dev/null
+++ b/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/CoreSourceGeneratorTests.cs
@@ -0,0 +1,212 @@
+using CliWrap;
+using CliWrap.EventStream;
+using dymaptic.GeoBlazor.Core.SourceGenerator.Tests.Utils;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Diagnostics;
+using ProtoBuf;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
+
+
+namespace dymaptic.GeoBlazor.Core.SourceGenerator.Tests;
+
+[TestClass]
+public class CoreSourceGeneratorTests
+{
+ [TestMethod]
+ public void TestCanTriggerESBuildInDebugMode()
+ {
+ string corePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!,
+ "..", "..", "..", "..", "..", "src", "dymaptic.GeoBlazor.Core");
+
+ var generator = new ESBuildGenerator();
+
+ // get actual Scripts files
+ var scriptsPath = Path.Combine(corePath, "Scripts");
+
+ IEnumerable additionalTexts = Directory
+ .GetFiles(scriptsPath, "*.ts")
+ .Select(f => new TestAdditionalFile(f, File.ReadAllText(f)));
+
+ var cSharpParseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest);
+
+ TestAnalyzerConfigOptionsProvider analyzerConfigOptions = new(new Dictionary
+ {
+ { "build_property.CoreProjectPath", corePath }, { "build_property.Configuration", "Debug" }
+ });
+
+ // Source generators should be tested using 'GeneratorDriver'.
+ GeneratorDriver driver = CSharpGeneratorDriver
+ .Create([generator.AsSourceGenerator()], additionalTexts, cSharpParseOptions, analyzerConfigOptions);
+
+ // Create a compilation that includes the dymaptic.GeoBlazor.Core sources so generated trees are merged with real trees.
+ var compilation = CreateCompilationWithCoreSources(corePath, cSharpParseOptions);
+
+ // Run generators. Don't forget to use the new compilation rather than the previous one.
+ driver.RunGeneratorsAndUpdateCompilation(compilation, out _,
+ out var diagnostics);
+
+ Trace.WriteLine(string.Join(Environment.NewLine, diagnostics.Select(d => d.GetMessage())));
+
+ Assert.IsTrue(diagnostics.Any(d => d.Id == "GBSourceGen"),
+ "Expected a GBSourceGen diagnostic from the generator.");
+
+ Assert.IsTrue(diagnostics.Any(d => d.GetMessage()
+ .Contains("Core ESBuild process completed successfully.")),
+ "Expected a Core ESBuild process completed successfully.");
+ }
+
+ [TestMethod]
+ public void TestCanTriggerESBuildInReleaseMode()
+ {
+ string corePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!,
+ "..", "..", "..", "..", "..", "src", "dymaptic.GeoBlazor.Core");
+
+ var generator = new ESBuildGenerator();
+
+ // get actual Scripts files
+ var scriptsPath = Path.Combine(corePath, "Scripts");
+
+ IEnumerable additionalTexts = Directory
+ .GetFiles(scriptsPath, "*.ts")
+ .Select(f => new TestAdditionalFile(f, File.ReadAllText(f)));
+
+ var cSharpParseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest);
+
+ TestAnalyzerConfigOptionsProvider analyzerConfigOptions = new(new Dictionary
+ {
+ { "build_property.CoreProjectPath", corePath },
+ { "build_property.Configuration", "Release" },
+ { "build_property.PipelineBuild", "false" }
+ });
+
+ // Source generators should be tested using 'GeneratorDriver'.
+ GeneratorDriver driver = CSharpGeneratorDriver
+ .Create([generator.AsSourceGenerator()], additionalTexts, cSharpParseOptions, analyzerConfigOptions);
+
+ var compilation = CreateCompilationWithCoreSources(corePath, cSharpParseOptions);
+
+ // Run generators. Don't forget to use the new compilation rather than the previous one.
+ driver.RunGeneratorsAndUpdateCompilation(compilation, out _,
+ out var diagnostics);
+
+ Trace.WriteLine(string.Join(Environment.NewLine, diagnostics.Select(d => d.GetMessage())));
+
+ Assert.IsTrue(diagnostics.Any(d => d.Id == "GBSourceGen"),
+ "Expected a GBSourceGen diagnostic from the generator.");
+
+ Assert.IsTrue(diagnostics.Any(d => d.GetMessage()
+ .Contains("Core ESBuild process completed successfully.")),
+ "Expected a Core ESBuild process completed successfully.");
+ }
+
+ [TestMethod]
+ public void TestCanSkipBuildInPipelineMode()
+ {
+ string corePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!,
+ "..", "..", "..", "..", "..", "src", "dymaptic.GeoBlazor.Core");
+
+ var generator = new ESBuildGenerator();
+
+ // get actual Scripts files
+ var scriptsPath = Path.Combine(corePath, "Scripts");
+
+ IEnumerable additionalTexts = Directory
+ .GetFiles(scriptsPath, "*.ts")
+ .Select(f => new TestAdditionalFile(f, File.ReadAllText(f)));
+
+ var cSharpParseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest);
+
+ TestAnalyzerConfigOptionsProvider analyzerConfigOptions = new(new Dictionary
+ {
+ { "build_property.CoreProjectPath", corePath },
+ { "build_property.Configuration", "Release" },
+ { "build_property.PipelineBuild", "true" }
+ });
+
+ // Source generators should be tested using 'GeneratorDriver'.
+ GeneratorDriver driver = CSharpGeneratorDriver
+ .Create([generator.AsSourceGenerator()], additionalTexts, cSharpParseOptions, analyzerConfigOptions);
+
+ // Create compilation with core sources so there are real syntax trees present
+ var compilation = CreateCompilationWithCoreSources(corePath, cSharpParseOptions);
+
+ // Run generators. Don't forget to use the new compilation rather than the previous one.
+ driver.RunGeneratorsAndUpdateCompilation(compilation, out _,
+ out var diagnostics);
+
+ Trace.WriteLine(string.Join(Environment.NewLine, diagnostics.Select(d => d.GetMessage())));
+
+ Assert.IsTrue(diagnostics.Any(d => d.Id == "GBSourceGen"),
+ "Expected a GBSourceGen diagnostic from the generator.");
+
+ // When running in pipeline mode the generator should not run the esbuild processes
+ Assert.IsFalse(diagnostics.Any(d => d.GetMessage()
+ .Contains("Core ESBuild process completed successfully.")),
+ "ESBuild ran when it should not have in PipelineBuild");
+ }
+
+ // Helper: create a compilation that includes the dymaptic.GeoBlazor.Core source files
+ private static CSharpCompilation CreateCompilationWithCoreSources(string corePath, CSharpParseOptions parseOptions)
+ {
+ // gather all .cs files from the core project
+ var csFiles = Directory.GetFiles(corePath, "*.cs", SearchOption.AllDirectories);
+
+ var trees = csFiles.Select(f => CSharpSyntaxTree.ParseText(File.ReadAllText(f), parseOptions, f))
+ .ToList();
+
+ // minimal set of references for compilation
+ IEnumerable referencePaths = new[]
+ {
+ typeof(object).Assembly.Location, typeof(Enumerable).Assembly.Location,
+ typeof(Attribute).Assembly.Location, typeof(Console).Assembly.Location,
+ typeof(ProtoContractAttribute).Assembly.Location, typeof(Cli).Assembly.Location,
+ typeof(EventStreamCommandExtensions).Assembly.Location
+ }
+ .Where(p => !string.IsNullOrEmpty(p))
+ .Distinct();
+
+ List references = referencePaths
+ .Select(p => MetadataReference.CreateFromFile(p))
+ .ToList();
+
+ return CSharpCompilation.Create(nameof(CoreSourceGeneratorTests), trees, references,
+ new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
+ }
+}
+
+public class TestAnalyzerConfigOptionsProvider(Dictionary options)
+ : AnalyzerConfigOptionsProvider
+{
+ public override AnalyzerConfigOptions GlobalOptions { get; } = new TestAnalyzerConfigOptions(options);
+
+ public override AnalyzerConfigOptions GetOptions(SyntaxTree tree)
+ {
+ return GlobalOptions;
+ }
+
+ public override AnalyzerConfigOptions GetOptions(AdditionalText textFile)
+ {
+ return GlobalOptions;
+ }
+}
+
+public class TestAnalyzerConfigOptions(Dictionary options)
+ : AnalyzerConfigOptions
+{
+ public override bool TryGetValue(string key, [NotNullWhen(true)] [UnscopedRef] out string? value)
+ {
+ if (options.TryGetValue(key, out var optionValue))
+ {
+ value = optionValue;
+
+ return true;
+ }
+
+ value = null;
+
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/ESBuildLauncherTests.cs b/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/ESBuildLauncherTests.cs
deleted file mode 100644
index 3fb97123f..000000000
--- a/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/ESBuildLauncherTests.cs
+++ /dev/null
@@ -1,180 +0,0 @@
-using dymaptic.GeoBlazor.Core.SourceGenerator.Tests.Utils;
-using Microsoft.CodeAnalysis;
-using Microsoft.CodeAnalysis.CSharp;
-using Microsoft.CodeAnalysis.Diagnostics;
-using Microsoft.VisualStudio.TestTools.UnitTesting;
-using System.Collections.Immutable;
-using System.Diagnostics.CodeAnalysis;
-using System.Reflection;
-
-
-namespace dymaptic.GeoBlazor.Core.SourceGenerator.Tests;
-
-[TestClass]
-public class ESBuildLauncherTests
-{
-
- [TestMethod]
- public void TestCanTriggerESBuildInDebugMode()
- {
- string corePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!,
- "..", "..", "..", "..", "..", "src", "dymaptic.GeoBlazor.Core");
-
- var generator = new ESBuildLauncher();
- bool resultsReceived = false;
- generator.Notification += (_, message) =>
- {
- Console.WriteLine(message);
- resultsReceived = true;
- };
-
- // get actual Scripts files
- string scriptsPath = Path.Combine(corePath, "Scripts");
- IEnumerable additionalTexts = Directory
- .GetFiles(scriptsPath, "*.ts")
- .Select(f => new TestAdditionalFile(f, File.ReadAllText(f)));
-
- CSharpParseOptions cSharpParseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp7_3);
-
- TestAnalyzerConfigOptionsProvider analyzerConfigOptions = new(new Dictionary
- {
- { "build_property.MSBuildProjectDirectory", corePath },
- { "build_property.Configuration", "Debug" },
- { "build_property.PipelineBuild", "false" }
- });
-
- // Source generators should be tested using 'GeneratorDriver'.
- GeneratorDriver driver = CSharpGeneratorDriver
- .Create([generator.AsSourceGenerator()], additionalTexts, cSharpParseOptions, analyzerConfigOptions);
-
- // To run generators, we can use an empty compilation.
- var compilation = CSharpCompilation.Create(nameof(ESBuildLauncherTests));
- // Run generators. Don't forget to use the new compilation rather than the previous one.
- driver.RunGeneratorsAndUpdateCompilation(compilation, out Compilation newCompilation,
- out ImmutableArray _);
-
- Assert.IsTrue(resultsReceived);
- Assert.IsNotEmpty(newCompilation.SyntaxTrees);
- Assert.Contains("ESBuildRecord", newCompilation.SyntaxTrees.First().ToString());
- Assert.Contains("private const string Configuration = \"Debug\";", newCompilation.SyntaxTrees.First().ToString());
- }
-
- [TestMethod]
- public void TestCanTriggerESBuildInReleaseMode()
- {
- string corePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!,
- "..", "..", "..", "..", "..", "src", "dymaptic.GeoBlazor.Core");
-
- var generator = new ESBuildLauncher();
- bool resultsReceived = false;
- generator.Notification += (_, message) =>
- {
- Console.WriteLine(message);
- resultsReceived = true;
- };
-
- // get actual Scripts files
- string scriptsPath = Path.Combine(corePath, "Scripts");
- IEnumerable additionalTexts = Directory
- .GetFiles(scriptsPath, "*.ts")
- .Select(f => new TestAdditionalFile(f, File.ReadAllText(f)));
-
- CSharpParseOptions cSharpParseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp7_3);
-
- TestAnalyzerConfigOptionsProvider analyzerConfigOptions = new(new Dictionary
- {
- { "build_property.MSBuildProjectDirectory", corePath },
- { "build_property.Configuration", "Release" },
- { "build_property.PipelineBuild", "false" }
- });
-
- // Source generators should be tested using 'GeneratorDriver'.
- GeneratorDriver driver = CSharpGeneratorDriver
- .Create([generator.AsSourceGenerator()], additionalTexts, cSharpParseOptions, analyzerConfigOptions);
-
- // To run generators, we can use an empty compilation.
- var compilation = CSharpCompilation.Create(nameof(ESBuildLauncherTests));
- // Run generators. Don't forget to use the new compilation rather than the previous one.
- driver.RunGeneratorsAndUpdateCompilation(compilation, out Compilation newCompilation,
- out ImmutableArray _);
-
- Assert.IsTrue(resultsReceived);
- Assert.IsNotEmpty(newCompilation.SyntaxTrees);
- Assert.Contains("ESBuildRecord", newCompilation.SyntaxTrees.First().ToString());
- Assert.Contains("private const string Configuration = \"Release\";", newCompilation.SyntaxTrees.First().ToString());
- }
-
- [TestMethod]
- public void TestCanSkipBuildInPipelineMode()
- {
- string corePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!,
- "..", "..", "..", "..", "..", "src", "dymaptic.GeoBlazor.Core");
-
- var generator = new ESBuildLauncher();
- bool resultsReceived = false;
- generator.Notification += (_, message) =>
- {
- Console.WriteLine(message);
- resultsReceived = true;
- };
-
- // get actual Scripts files
- string scriptsPath = Path.Combine(corePath, "Scripts");
- IEnumerable additionalTexts = Directory
- .GetFiles(scriptsPath, "*.ts")
- .Select(f => new TestAdditionalFile(f, File.ReadAllText(f)));
-
- CSharpParseOptions cSharpParseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp7_3);
-
- TestAnalyzerConfigOptionsProvider analyzerConfigOptions = new(new Dictionary
- {
- { "build_property.MSBuildProjectDirectory", corePath },
- { "build_property.Configuration", "Release" },
- { "build_property.PipelineBuild", "true" }
- });
-
- // Source generators should be tested using 'GeneratorDriver'.
- GeneratorDriver driver = CSharpGeneratorDriver
- .Create([generator.AsSourceGenerator()], additionalTexts, cSharpParseOptions, analyzerConfigOptions);
-
- // To run generators, we can use an empty compilation.
- var compilation = CSharpCompilation.Create(nameof(ESBuildLauncherTests));
- // Run generators. Don't forget to use the new compilation rather than the previous one.
- driver.RunGeneratorsAndUpdateCompilation(compilation, out Compilation newCompilation,
- out ImmutableArray _);
- Assert.IsEmpty(newCompilation.SyntaxTrees);
- Assert.IsTrue(resultsReceived);
- }
-}
-
-public class TestAnalyzerConfigOptionsProvider(Dictionary options)
- : AnalyzerConfigOptionsProvider
-{
- public override AnalyzerConfigOptions GetOptions(SyntaxTree tree)
- {
- return GlobalOptions;
- }
-
- public override AnalyzerConfigOptions GetOptions(AdditionalText textFile)
- {
- return GlobalOptions;
- }
-
- public override AnalyzerConfigOptions GlobalOptions { get; } = new TestAnalyzerConfigOptions(options);
-}
-
-public class TestAnalyzerConfigOptions(Dictionary options)
- : AnalyzerConfigOptions
-{
- public override bool TryGetValue(string key, [NotNullWhen(true)] [UnscopedRef] out string? value)
- {
- if (options.TryGetValue(key, out string? optionValue))
- {
- value = optionValue;
- return true;
- }
-
- value = null;
- return false;
- }
-}
\ No newline at end of file
diff --git a/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/ProtobufDefinitionsGeneratorTests.cs b/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/ProtobufDefinitionsGeneratorTests.cs
new file mode 100644
index 000000000..cdc0e27b5
--- /dev/null
+++ b/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/ProtobufDefinitionsGeneratorTests.cs
@@ -0,0 +1,724 @@
+using dymaptic.GeoBlazor.Core.SourceGenerator.Shared;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using System.Reflection;
+
+
+namespace dymaptic.GeoBlazor.Core.SourceGenerator.Tests;
+
+///
+/// Tests for the ProtobufDefinitionsGenerator class which extracts and generates
+/// Protobuf schema definitions from C# types marked with ProtoContract attributes.
+///
+///
+/// These tests use reflection to test the private extraction methods directly,
+/// bypassing the SourceProductionContext dependency which cannot be properly
+/// initialized outside the Roslyn infrastructure.
+///
+[TestClass]
+public class ProtobufDefinitionsGeneratorTests
+{
+ ///
+ /// Tests that a simple type with ProtoContract and ProtoMember attributes
+ /// is correctly extracted into a ProtoMessageDefinition.
+ ///
+ [TestMethod]
+ public void ExtractMessageDefinition_ExtractsSimpleProtoMessage()
+ {
+ // Arrange
+ var source = """
+ using ProtoBuf;
+
+ namespace Test;
+
+ [ProtoContract(Name = "SimpleMessage")]
+ public record SimpleSerializationRecord
+ {
+ [ProtoMember(1)]
+ public string? Name { get; init; }
+
+ [ProtoMember(2)]
+ public int Value { get; init; }
+ }
+ """;
+
+ var type = ParseSingleTypeFromSource(source, "SimpleSerializationRecord");
+
+ // Act
+ var messageDef = ExtractMessageDefinition(type);
+
+ // Assert
+ Assert.IsNotNull(messageDef, "Expected a message definition to be extracted.");
+ Assert.AreEqual("SimpleMessage", messageDef.Name);
+ Assert.HasCount(2, messageDef.Fields);
+
+ var nameField = messageDef.Fields.First(f => f.Name == "name");
+ Assert.AreEqual("string", nameField.Type);
+ Assert.AreEqual(1, nameField.Number);
+
+ var valueField = messageDef.Fields.First(f => f.Name == "value");
+ Assert.AreEqual("int32", valueField.Type);
+ Assert.AreEqual(2, valueField.Number);
+ }
+
+ ///
+ /// Tests that types without ProtoContract attribute return null.
+ ///
+ [TestMethod]
+ public void ExtractMessageDefinition_ReturnsNullForTypesWithoutProtoContract()
+ {
+ // Arrange
+ var source = """
+ namespace Test;
+
+ public record NonProtoRecord
+ {
+ public string? Name { get; init; }
+ }
+ """;
+
+ var type = ParseSingleTypeFromSource(source, "NonProtoRecord");
+
+ // Act
+ var messageDef = ExtractMessageDefinition(type);
+
+ // Assert
+ Assert.IsNull(messageDef,
+ "Expected null for types without ProtoContract attribute.");
+ }
+
+ ///
+ /// Tests that various C# types are correctly converted to Protobuf types.
+ ///
+ [TestMethod]
+ public void ConvertCSharpTypeToProtoType_ConvertsBasicTypesCorrectly()
+ {
+ // Assert basic type conversions
+ Assert.AreEqual("string", ConvertCSharpTypeToProtoType("string"));
+ Assert.AreEqual("string", ConvertCSharpTypeToProtoType("string?"));
+ Assert.AreEqual("int32", ConvertCSharpTypeToProtoType("int"));
+ Assert.AreEqual("int32", ConvertCSharpTypeToProtoType("int?"));
+ Assert.AreEqual("int64", ConvertCSharpTypeToProtoType("long"));
+ Assert.AreEqual("int64", ConvertCSharpTypeToProtoType("long?"));
+ Assert.AreEqual("double", ConvertCSharpTypeToProtoType("double"));
+ Assert.AreEqual("double", ConvertCSharpTypeToProtoType("double?"));
+ Assert.AreEqual("float", ConvertCSharpTypeToProtoType("float"));
+ Assert.AreEqual("float", ConvertCSharpTypeToProtoType("float?"));
+ Assert.AreEqual("bool", ConvertCSharpTypeToProtoType("bool"));
+ Assert.AreEqual("bool", ConvertCSharpTypeToProtoType("bool?"));
+ Assert.AreEqual("bytes", ConvertCSharpTypeToProtoType("byte[]"));
+ Assert.AreEqual("bytes", ConvertCSharpTypeToProtoType("byte[]?"));
+ }
+
+ ///
+ /// Tests that collection types are converted to repeated fields.
+ ///
+ [TestMethod]
+ public void ConvertCSharpTypeToProtoType_ConvertsCollectionsToRepeated()
+ {
+ // Assert collection type conversions
+ Assert.AreEqual("repeated string", ConvertCSharpTypeToProtoType("string[]"));
+ Assert.AreEqual("repeated int32", ConvertCSharpTypeToProtoType("int[]"));
+ Assert.AreEqual("repeated string", ConvertCSharpTypeToProtoType("List"));
+ Assert.AreEqual("repeated int32", ConvertCSharpTypeToProtoType("List"));
+ Assert.AreEqual("repeated string", ConvertCSharpTypeToProtoType("IEnumerable"));
+ Assert.AreEqual("repeated int32", ConvertCSharpTypeToProtoType("IList"));
+ }
+
+ ///
+ /// Tests that SerializationRecord suffix is removed from type references.
+ ///
+ [TestMethod]
+ public void ConvertCSharpTypeToProtoType_RemovesSerializationRecordSuffix()
+ {
+ // Assert SerializationRecord suffix handling
+ Assert.AreEqual("Child", ConvertCSharpTypeToProtoType("ChildSerializationRecord"));
+ Assert.AreEqual("repeated Child", ConvertCSharpTypeToProtoType("ChildSerializationRecord[]"));
+ Assert.AreEqual("repeated Child", ConvertCSharpTypeToProtoType("List"));
+ }
+
+ ///
+ /// Tests that ProtoInclude attributes are correctly extracted for polymorphic types.
+ ///
+ [TestMethod]
+ public void ExtractMessageDefinition_ExtractsProtoIncludes()
+ {
+ // Arrange
+ var source = """
+ using ProtoBuf;
+
+ namespace Test;
+
+ [ProtoContract(Name = "BaseType")]
+ [ProtoInclude(100, typeof(DerivedOneSerializationRecord))]
+ [ProtoInclude(101, typeof(DerivedTwoSerializationRecord))]
+ public record BaseTypeSerializationRecord
+ {
+ [ProtoMember(1)]
+ public string? CommonField { get; init; }
+ }
+
+ [ProtoContract(Name = "DerivedOne")]
+ public record DerivedOneSerializationRecord : BaseTypeSerializationRecord
+ {
+ [ProtoMember(2)]
+ public int SpecificField { get; init; }
+ }
+
+ [ProtoContract(Name = "DerivedTwo")]
+ public record DerivedTwoSerializationRecord : BaseTypeSerializationRecord
+ {
+ [ProtoMember(3)]
+ public double OtherField { get; init; }
+ }
+ """;
+
+ var type = ParseSingleTypeFromSource(source, "BaseTypeSerializationRecord");
+
+ // Act
+ var messageDef = ExtractMessageDefinition(type);
+
+ // Assert
+ Assert.IsNotNull(messageDef);
+ Assert.AreEqual("BaseType", messageDef.Name);
+
+ Assert.HasCount(2, messageDef.ProtoIncludes,
+ "Expected 2 ProtoInclude entries.");
+
+ var include100 = messageDef.ProtoIncludes.First(p => p.Tag == 100);
+ Assert.AreEqual("DerivedOneSerializationRecord", include100.TypeName);
+
+ var include101 = messageDef.ProtoIncludes.First(p => p.Tag == 101);
+ Assert.AreEqual("DerivedTwoSerializationRecord", include101.TypeName);
+ }
+
+ ///
+ /// Tests that the message name is extracted from the ProtoContract Name parameter.
+ ///
+ [TestMethod]
+ public void ExtractMessageDefinition_UsesProtoContractNameParameter()
+ {
+ // Arrange
+ var source = """
+ using ProtoBuf;
+
+ namespace Test;
+
+ [ProtoContract(Name = "CustomMessageName")]
+ public record SomeSerializationRecord
+ {
+ [ProtoMember(1)]
+ public string? Field { get; init; }
+ }
+ """;
+
+ var type = ParseSingleTypeFromSource(source, "SomeSerializationRecord");
+
+ // Act
+ var messageDef = ExtractMessageDefinition(type);
+
+ // Assert
+ Assert.IsNotNull(messageDef);
+
+ Assert.AreEqual("CustomMessageName", messageDef.Name,
+ "Expected message to use name from ProtoContract attribute.");
+ }
+
+ ///
+ /// Tests that fields are ordered by their ProtoMember field number.
+ ///
+ [TestMethod]
+ public void ExtractMessageDefinition_OrdersFieldsByNumber()
+ {
+ // Arrange
+ var source = """
+ using ProtoBuf;
+
+ namespace Test;
+
+ [ProtoContract(Name = "OrderedFields")]
+ public record OrderedFieldsSerializationRecord
+ {
+ [ProtoMember(3)]
+ public string? Third { get; init; }
+
+ [ProtoMember(1)]
+ public string? First { get; init; }
+
+ [ProtoMember(2)]
+ public string? Second { get; init; }
+ }
+ """;
+
+ var type = ParseSingleTypeFromSource(source, "OrderedFieldsSerializationRecord");
+
+ // Act
+ var messageDef = ExtractMessageDefinition(type);
+
+ // Assert
+ Assert.IsNotNull(messageDef);
+ Assert.HasCount(3, messageDef.Fields);
+ Assert.AreEqual(1, messageDef.Fields[0].Number);
+ Assert.AreEqual("first", messageDef.Fields[0].Name);
+ Assert.AreEqual(2, messageDef.Fields[1].Number);
+ Assert.AreEqual("second", messageDef.Fields[1].Name);
+ Assert.AreEqual(3, messageDef.Fields[2].Number);
+ Assert.AreEqual("third", messageDef.Fields[2].Name);
+ }
+
+ ///
+ /// Tests that properties without ProtoMember attribute are skipped.
+ ///
+ [TestMethod]
+ public void ExtractMessageDefinition_SkipsPropertiesWithoutProtoMember()
+ {
+ // Arrange
+ var source = """
+ using ProtoBuf;
+
+ namespace Test;
+
+ [ProtoContract(Name = "PartialMessage")]
+ public record PartialSerializationRecord
+ {
+ [ProtoMember(1)]
+ public string? IncludedField { get; init; }
+
+ public string? ExcludedField { get; init; }
+ }
+ """;
+
+ var type = ParseSingleTypeFromSource(source, "PartialSerializationRecord");
+
+ // Act
+ var messageDef = ExtractMessageDefinition(type);
+
+ // Assert
+ Assert.IsNotNull(messageDef);
+
+ Assert.HasCount(1, messageDef.Fields,
+ "Expected only the field with ProtoMember attribute to be included.");
+ Assert.AreEqual("includedField", messageDef.Fields[0].Name);
+ }
+
+ ///
+ /// Tests that nullable types are handled correctly (nullable marker removed).
+ ///
+ [TestMethod]
+ public void ExtractMessageDefinition_HandlesNullableTypes()
+ {
+ // Arrange
+ var source = """
+ using ProtoBuf;
+
+ namespace Test;
+
+ [ProtoContract(Name = "NullableTypes")]
+ public record NullableTypesSerializationRecord
+ {
+ [ProtoMember(1)]
+ public string? NullableString { get; init; }
+
+ [ProtoMember(2)]
+ public int? NullableInt { get; init; }
+
+ [ProtoMember(3)]
+ public double? NullableDouble { get; init; }
+ }
+ """;
+
+ var type = ParseSingleTypeFromSource(source, "NullableTypesSerializationRecord");
+
+ // Act
+ var messageDef = ExtractMessageDefinition(type);
+
+ // Assert
+ Assert.IsNotNull(messageDef);
+
+ // Nullable markers should be stripped from the protobuf type
+ AssertFieldType(messageDef, "nullableString", "string");
+ AssertFieldType(messageDef, "nullableInt", "int32");
+ AssertFieldType(messageDef, "nullableDouble", "double");
+ }
+
+ ///
+ /// Tests that SerializationRecord suffix is handled correctly for nested types.
+ ///
+ [TestMethod]
+ public void ExtractMessageDefinition_HandlesSerializationRecordReferences()
+ {
+ // Arrange
+ var source = """
+ using ProtoBuf;
+
+ namespace Test;
+
+ [ProtoContract(Name = "Parent")]
+ public record ParentSerializationRecord
+ {
+ [ProtoMember(1)]
+ public ChildSerializationRecord? Child { get; init; }
+ }
+
+ [ProtoContract(Name = "Child")]
+ public record ChildSerializationRecord
+ {
+ [ProtoMember(1)]
+ public string? Name { get; init; }
+ }
+ """;
+
+ var type = ParseSingleTypeFromSource(source, "ParentSerializationRecord");
+
+ // Act
+ var messageDef = ExtractMessageDefinition(type);
+
+ // Assert
+ Assert.IsNotNull(messageDef);
+
+ // The type should have SerializationRecord suffix removed
+ var childField = messageDef.Fields.First(f => f.Name == "child");
+
+ Assert.AreEqual("Child", childField.Type,
+ "Expected 'SerializationRecord' suffix to be removed from the type reference.");
+ }
+
+ ///
+ /// Tests that IEnumerable collections are converted to repeated fields.
+ ///
+ [TestMethod]
+ public void ExtractMessageDefinition_HandlesIEnumerableAsRepeated()
+ {
+ // Arrange
+ var source = """
+ using ProtoBuf;
+ using System.Collections.Generic;
+
+ namespace Test;
+
+ [ProtoContract(Name = "EnumerableTest")]
+ public record EnumerableSerializationRecord
+ {
+ [ProtoMember(1)]
+ public IEnumerable? StringItems { get; init; }
+
+ [ProtoMember(2)]
+ public IList? IntItems { get; init; }
+ }
+ """;
+
+ var type = ParseSingleTypeFromSource(source, "EnumerableSerializationRecord");
+
+ // Act
+ var messageDef = ExtractMessageDefinition(type);
+
+ // Assert
+ Assert.IsNotNull(messageDef);
+ AssertFieldType(messageDef, "stringItems", "repeated string");
+ AssertFieldType(messageDef, "intItems", "repeated int32");
+ }
+
+ ///
+ /// Tests that the generator handles interface base types correctly.
+ ///
+ [TestMethod]
+ public void ExtractMessageDefinition_DetectsInterfaceBaseType()
+ {
+ // Arrange
+ var source = """
+ using ProtoBuf;
+
+ namespace Test;
+
+ public interface ITestInterface { }
+
+ [ProtoContract(Name = "InterfaceImpl")]
+ public record InterfaceImplSerializationRecord : MapComponentSerializationRecord
+ {
+ [ProtoMember(1)]
+ public string? Field { get; init; }
+ }
+
+ public abstract record MapComponentSerializationRecord { }
+ """;
+
+ var type = ParseSingleTypeFromSource(source, "InterfaceImplSerializationRecord");
+
+ // Act
+ var messageDef = ExtractMessageDefinition(type);
+
+ // Assert
+ Assert.IsNotNull(messageDef);
+
+ Assert.IsTrue(messageDef.GeoBlazorTypeIsInterface,
+ "Expected GeoBlazorTypeIsInterface to be true for types implementing interface-based generics.");
+ }
+
+ ///
+ /// Tests that the generator falls back to type identifier when no Name parameter is specified.
+ ///
+ [TestMethod]
+ public void ExtractMessageDefinition_FallsBackToTypeIdentifier()
+ {
+ // Arrange
+ var source = """
+ using ProtoBuf;
+
+ namespace Test;
+
+ [ProtoContract]
+ public record FallbackSerializationRecord
+ {
+ [ProtoMember(1)]
+ public string? Field { get; init; }
+ }
+ """;
+
+ var type = ParseSingleTypeFromSource(source, "FallbackSerializationRecord");
+
+ // Act
+ var messageDef = ExtractMessageDefinition(type);
+
+ // Assert
+ Assert.IsNotNull(messageDef);
+
+ Assert.AreEqual("FallbackSerializationRecord", messageDef.Name,
+ "Expected type identifier to be used as message name when Name parameter is not specified.");
+ }
+
+ ///
+ /// Tests that empty types with no fields are still processed.
+ ///
+ [TestMethod]
+ public void ExtractMessageDefinition_HandlesEmptyTypes()
+ {
+ // Arrange
+ var source = """
+ using ProtoBuf;
+
+ namespace Test;
+
+ [ProtoContract(Name = "EmptyMessage")]
+ public record EmptySerializationRecord
+ {
+ }
+ """;
+
+ var type = ParseSingleTypeFromSource(source, "EmptySerializationRecord");
+
+ // Act
+ var messageDef = ExtractMessageDefinition(type);
+
+ // Assert
+ Assert.IsNotNull(messageDef);
+ Assert.IsEmpty(messageDef.Fields, "Expected no fields for empty message.");
+ }
+
+ ///
+ /// Tests that ProtoIncludes are ordered by tag number.
+ ///
+ [TestMethod]
+ public void ExtractMessageDefinition_OrdersProtoIncludesByTag()
+ {
+ // Arrange
+ var source = """
+ using ProtoBuf;
+
+ namespace Test;
+
+ [ProtoContract(Name = "OrderedIncludes")]
+ [ProtoInclude(300, typeof(ThirdSerializationRecord))]
+ [ProtoInclude(100, typeof(FirstSerializationRecord))]
+ [ProtoInclude(200, typeof(SecondSerializationRecord))]
+ public record OrderedIncludesSerializationRecord
+ {
+ }
+
+ [ProtoContract(Name = "First")]
+ public record FirstSerializationRecord { }
+
+ [ProtoContract(Name = "Second")]
+ public record SecondSerializationRecord { }
+
+ [ProtoContract(Name = "Third")]
+ public record ThirdSerializationRecord { }
+ """;
+
+ var type = ParseSingleTypeFromSource(source, "OrderedIncludesSerializationRecord");
+
+ // Act
+ var messageDef = ExtractMessageDefinition(type);
+
+ // Assert
+ Assert.IsNotNull(messageDef);
+ Assert.HasCount(3, messageDef.ProtoIncludes);
+ Assert.AreEqual(100, messageDef.ProtoIncludes[0].Tag);
+ Assert.AreEqual(200, messageDef.ProtoIncludes[1].Tag);
+ Assert.AreEqual(300, messageDef.ProtoIncludes[2].Tag);
+ }
+
+ ///
+ /// Tests that GenerateProtoFileContent generates valid proto3 syntax.
+ ///
+ [TestMethod]
+ public void GenerateProtoFileContent_GeneratesValidProto3Header()
+ {
+ // Arrange
+ var definitions = new Dictionary
+ {
+ ["TestMessage"] = new()
+ {
+ Name = "TestMessage",
+ Fields = [new ProtoFieldDefinition { Name = "field", Type = "string", Number = 1 }]
+ }
+ };
+
+ // Act
+ var content = GenerateProtoFileContent(definitions);
+
+ // Assert
+ Assert.Contains("syntax = \"proto3\";", content,
+ "Expected proto3 syntax declaration.");
+
+ Assert.Contains("package dymaptic.GeoBlazor.Core.Serialization;", content,
+ "Expected package declaration.");
+
+ Assert.Contains("import \"google/protobuf/empty.proto\";", content,
+ "Expected empty.proto import.");
+ }
+
+ ///
+ /// Tests that GenerateProtoFileContent generates message definitions correctly.
+ ///
+ [TestMethod]
+ public void GenerateProtoFileContent_GeneratesMessageDefinitions()
+ {
+ // Arrange
+ var definitions = new Dictionary
+ {
+ ["SimpleMessage"] = new()
+ {
+ Name = "SimpleMessage",
+ Fields =
+ [
+ new ProtoFieldDefinition { Name = "name", Type = "string", Number = 1 },
+ new ProtoFieldDefinition { Name = "value", Type = "int32", Number = 2 }
+ ]
+ }
+ };
+
+ // Act
+ var content = GenerateProtoFileContent(definitions);
+
+ // Assert
+ Assert.Contains("message SimpleMessage {", content,
+ "Expected message declaration.");
+
+ Assert.Contains("string name = 1;", content,
+ "Expected name field declaration.");
+
+ Assert.Contains("int32 value = 2;", content,
+ "Expected value field declaration.");
+ }
+
+ // Reflection-based access to private methods
+ private static readonly MethodInfo extractMessageDefinitionMethod =
+ typeof(ProtobufDefinitionsGenerator).GetMethod("ExtractMessageDefinition",
+ BindingFlags.NonPublic | BindingFlags.Static)!;
+
+ private static readonly MethodInfo convertCSharpTypeToProtoTypeMethod =
+ typeof(ProtobufDefinitionsGenerator).GetMethod("ConvertCSharpTypeToProtoType",
+ BindingFlags.NonPublic | BindingFlags.Static)!;
+
+ private static readonly MethodInfo generateProtoFileContentMethod =
+ typeof(ProtobufDefinitionsGenerator).GetMethod("GenerateProtoFileContent",
+ BindingFlags.NonPublic | BindingFlags.Static)!;
+
+
+#region Helper Methods
+
+ ///
+ /// Parses C# source code and extracts a specific type declaration by name.
+ ///
+ private static BaseTypeDeclarationSyntax ParseSingleTypeFromSource(string source, string typeName)
+ {
+ var sourceWithProtobuf = PROTOBUF_ATTRIBUTE_STUB + source;
+ var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest);
+ var tree = CSharpSyntaxTree.ParseText(sourceWithProtobuf, parseOptions);
+ var root = tree.GetCompilationUnitRoot();
+
+ var type = root.DescendantNodes()
+ .OfType()
+ .FirstOrDefault(t => t.Identifier.Text == typeName);
+
+ return type ?? throw new InvalidOperationException($"Type '{typeName}' not found in source.");
+ }
+
+ ///
+ /// Invokes the private ExtractMessageDefinition method via reflection.
+ ///
+ private static ProtoMessageDefinition? ExtractMessageDefinition(BaseTypeDeclarationSyntax type)
+ {
+ return (ProtoMessageDefinition?)extractMessageDefinitionMethod.Invoke(null, [type]);
+ }
+
+ ///
+ /// Invokes the private ConvertCSharpTypeToProtoType method via reflection.
+ ///
+ private static string ConvertCSharpTypeToProtoType(string csharpType)
+ {
+ return (string)convertCSharpTypeToProtoTypeMethod.Invoke(null, [csharpType])!;
+ }
+
+ ///
+ /// Invokes the private GenerateProtoFileContent method via reflection.
+ ///
+ private static string GenerateProtoFileContent(Dictionary definitions)
+ {
+ return (string)generateProtoFileContentMethod.Invoke(null, [definitions])!;
+ }
+
+ ///
+ /// Asserts that a field with the given name has the expected Protobuf type.
+ ///
+ private static void AssertFieldType(ProtoMessageDefinition messageDef, string fieldName, string expectedType)
+ {
+ var field = messageDef.Fields.FirstOrDefault(f => f.Name == fieldName);
+ Assert.IsNotNull(field, $"Expected field '{fieldName}' to exist in message '{messageDef.Name}'.");
+
+ Assert.AreEqual(expectedType, field.Type,
+ $"Expected field '{fieldName}' to have type '{expectedType}' but was '{field.Type}'.");
+ }
+
+ ///
+ /// Stub definitions for ProtoBuf attributes to enable parsing without full ProtoBuf reference.
+ ///
+ private const string PROTOBUF_ATTRIBUTE_STUB = """
+ namespace ProtoBuf
+ {
+ [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Struct)]
+ public sealed class ProtoContractAttribute : System.Attribute
+ {
+ public string? Name { get; set; }
+ }
+
+ [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Field)]
+ public sealed class ProtoMemberAttribute : System.Attribute
+ {
+ public ProtoMemberAttribute(int tag) { Tag = tag; }
+ public int Tag { get; }
+ }
+
+ [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Struct, AllowMultiple = true)]
+ public sealed class ProtoIncludeAttribute : System.Attribute
+ {
+ public ProtoIncludeAttribute(int tag, System.Type knownType) { Tag = tag; KnownType = knownType; }
+ public int Tag { get; }
+ public System.Type KnownType { get; }
+ }
+ }
+
+ """;
+
+#endregion
+}
\ No newline at end of file
diff --git a/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/Utils/RoslynUtility.cs b/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/Utils/RoslynUtility.cs
new file mode 100644
index 000000000..943f8b7b4
--- /dev/null
+++ b/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/Utils/RoslynUtility.cs
@@ -0,0 +1,56 @@
+using Microsoft.AspNetCore.Components;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.Extensions.DependencyInjection;
+using System.Reflection;
+
+
+[assembly: DoNotParallelize]
+
+
+namespace dymaptic.GeoBlazor.Core.SourceGenerator.Tests.Utils;
+
+public static class RoslynUtility
+{
+ public static string CoreDllPath => Path.Combine(CorePath, "bin", "Debug", "net8.0",
+ "dymaptic.GeoBlazor.Core.dll");
+
+ public static string CorePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!,
+ "..", "..", "..", "..", "..", "src", "dymaptic.GeoBlazor.Core");
+
+ public static CSharpCompilation CreateCompilation(string sourceCode)
+ {
+ CSharpParseOptions options = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp7_3);
+ SyntaxTree tree = CSharpSyntaxTree.ParseText(sourceCode, options);
+ SyntaxTree protobufStubTree = CSharpSyntaxTree.ParseText(protobufStub, options);
+
+ PortableExecutableReference[] references = new[]
+ {
+ typeof(object).Assembly, // System.Runtime
+ typeof(Console).Assembly, // System.Console
+ typeof(ComponentBase).Assembly, // Microsoft.AspNetCore.Components
+ typeof(ActivatorUtilitiesConstructorAttribute).Assembly, // Microsoft.Extensions.DependencyInjection
+ Assembly.Load("System.Runtime"), Assembly.Load("netstandard"),
+ Assembly.LoadFile(CoreDllPath) // dymaptic.GeoBlazor.Core
+ }
+ .Select(a => MetadataReference.CreateFromFile(a.Location))
+ .ToArray();
+
+ return CSharpCompilation.Create("dymaptic.GeoBlazor.Core",
+ [tree, protobufStubTree],
+ references,
+ new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
+ }
+
+ private static readonly string protobufStub = """
+
+
+ namespace ProtoBuf
+ {
+ [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Struct)]
+ public sealed class ProtoContractAttribute : System.Attribute
+ {
+ }
+ }
+ """;
+}
\ No newline at end of file
diff --git a/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/dymaptic.GeoBlazor.Core.SourceGenerator.Tests.csproj b/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/dymaptic.GeoBlazor.Core.SourceGenerator.Tests.csproj
index bb4638238..54a30f3c5 100644
--- a/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/dymaptic.GeoBlazor.Core.SourceGenerator.Tests.csproj
+++ b/test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/dymaptic.GeoBlazor.Core.SourceGenerator.Tests.csproj
@@ -1,4 +1,4 @@
-
+
net10.0
@@ -7,22 +7,34 @@
false
dymaptic.GeoBlazor.Core.SourceGenerator.Tests
+ true
+
+
+ true
+ Exe
+
+
+
+
+
-
-
runtime; build; native; contentfiles; analyzers; buildtransitive
all
+
-
+
+
diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/BrowserPool.cs b/test/dymaptic.GeoBlazor.Core.Test.Automation/BrowserPool.cs
index 9136183d0..8da268b47 100644
--- a/test/dymaptic.GeoBlazor.Core.Test.Automation/BrowserPool.cs
+++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/BrowserPool.cs
@@ -11,24 +11,6 @@ namespace dymaptic.GeoBlazor.Core.Test.Automation;
///
public sealed class BrowserPool : IAsyncDisposable
{
- private static BrowserPool? _instance;
- private static readonly Lock _instanceLock = new();
-
- private readonly ConcurrentQueue _availableBrowsers = new();
- private readonly ConcurrentDictionary _checkedOutBrowsers = new();
- private readonly SemaphoreSlim _poolSemaphore;
- private readonly SemaphoreSlim _creationLock = new(1, 1);
- private readonly BrowserTypeLaunchOptions _launchOptions;
- private readonly IBrowserType _browserType;
- private readonly int _maxPoolSize;
- private int _currentPoolSize;
- private bool _disposed;
-
- ///
- /// Maximum time to wait for a browser from the pool (5 minutes)
- ///
- private static readonly TimeSpan CheckoutTimeout = TimeSpan.FromMinutes(5);
-
private BrowserPool(IBrowserType browserType, BrowserTypeLaunchOptions launchOptions, int maxPoolSize)
{
_browserType = browserType;
@@ -37,10 +19,41 @@ private BrowserPool(IBrowserType browserType, BrowserTypeLaunchOptions launchOpt
_poolSemaphore = new SemaphoreSlim(maxPoolSize, maxPoolSize);
}
+ public async ValueTask DisposeAsync()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+
+ // Dispose all available browsers
+ while (_availableBrowsers.TryDequeue(out var browser))
+ {
+ await browser.DisposeAsync().ConfigureAwait(false);
+ }
+
+ // Dispose all checked out browsers
+ foreach (var browser in _checkedOutBrowsers.Values)
+ {
+ await browser.DisposeAsync().ConfigureAwait(false);
+ }
+
+ _checkedOutBrowsers.Clear();
+
+ _poolSemaphore.Dispose();
+ _creationLock.Dispose();
+
+ _instance = null;
+ Trace.WriteLine("Browser pool disposed", "BROWSER_POOL");
+ }
+
///
/// Gets or creates the singleton browser pool instance.
///
- public static BrowserPool GetInstance(IBrowserType browserType, BrowserTypeLaunchOptions launchOptions, int maxPoolSize = 2)
+ public static BrowserPool GetInstance(IBrowserType browserType, BrowserTypeLaunchOptions launchOptions,
+ int maxPoolSize = 2)
{
if (_instance is null)
{
@@ -113,10 +126,14 @@ public async Task CheckoutAsync(CancellationToken cancellationTok
newPooledBrowser.MarkCheckedOut();
_checkedOutBrowsers[newPooledBrowser.Id] = newPooledBrowser;
Interlocked.Increment(ref _currentPoolSize);
+
Trace.WriteLine(
$"Created new browser {newPooledBrowser.Id}, pool size: {_currentPoolSize}/{_maxPoolSize}",
"BROWSER_POOL");
+ // Trigger pre-warming of additional browsers on first checkout
+ TriggerPreWarm();
+
return newPooledBrowser;
}
finally
@@ -196,38 +213,122 @@ public async Task ReportFailedAsync(PooledBrowser pooledBrowser)
Trace.WriteLine($"Removed failed browser {pooledBrowser.Id} from pool", "BROWSER_POOL");
}
- public async ValueTask DisposeAsync()
+ ///
+ /// Gets pool statistics for diagnostics
+ ///
+ public (int Available, int CheckedOut, int TotalCreated) GetStats() =>
+ (_availableBrowsers.Count, _checkedOutBrowsers.Count, _currentPoolSize);
+
+ ///
+ /// Triggers background pre-warming of additional browser instances.
+ /// Called once after the first browser is created.
+ ///
+ private void TriggerPreWarm()
{
- if (_disposed) return;
+ if (_preWarmStarted || _disposed)
+ {
+ return;
+ }
- _disposed = true;
+ _preWarmStarted = true;
- // Dispose all available browsers
- while (_availableBrowsers.TryDequeue(out var browser))
+ // Calculate how many additional browsers to pre-warm
+ // Leave at least 1 slot for the current checkout
+ var browsersToCreate = _maxPoolSize - 1;
+
+ if (browsersToCreate <= 0)
{
- await browser.DisposeAsync().ConfigureAwait(false);
+ return;
}
- // Dispose all checked out browsers
- foreach (var browser in _checkedOutBrowsers.Values)
+ Trace.WriteLine($"Pre-warming {browsersToCreate} additional browser(s) in background", "BROWSER_POOL");
+
+ // Fire and forget - pre-warm in background
+ _ = PreWarmAsync(browsersToCreate);
+ }
+
+ ///
+ /// Pre-warms the pool by creating additional browser instances in the background.
+ ///
+ private async Task PreWarmAsync(int count)
+ {
+ var tasks = new List();
+
+ for (var i = 0; i < count; i++)
{
- await browser.DisposeAsync().ConfigureAwait(false);
+ tasks.Add(CreatePreWarmBrowserAsync());
}
- _checkedOutBrowsers.Clear();
+ await Task.WhenAll(tasks).ConfigureAwait(false);
- _poolSemaphore.Dispose();
- _creationLock.Dispose();
+ var stats = GetStats();
- _instance = null;
- Trace.WriteLine("Browser pool disposed", "BROWSER_POOL");
+ Trace.WriteLine($"Pre-warm complete. Pool stats: {stats.Available} available, {stats.CheckedOut} checked out, {
+ stats.TotalCreated} total", "BROWSER_POOL");
}
///
- /// Gets pool statistics for diagnostics
+ /// Creates a single browser for pre-warming and adds it to the available queue.
+ /// Pre-warmed browsers do NOT hold semaphore slots - slots are acquired on checkout.
///
- public (int Available, int CheckedOut, int TotalCreated) GetStats() =>
- (_availableBrowsers.Count, _checkedOutBrowsers.Count, _currentPoolSize);
+ private async Task CreatePreWarmBrowserAsync()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ try
+ {
+ await _creationLock.WaitAsync().ConfigureAwait(false);
+
+ try
+ {
+ // Check if we've already hit the max pool size
+ if (_currentPoolSize >= _maxPoolSize)
+ {
+ return;
+ }
+
+ var browser = await _browserType.LaunchAsync(_launchOptions).ConfigureAwait(false);
+ var pooledBrowser = new PooledBrowser(browser, this);
+ Interlocked.Increment(ref _currentPoolSize);
+
+ // Add directly to available queue (not checked out, no semaphore slot held)
+ _availableBrowsers.Enqueue(pooledBrowser);
+
+ Trace.WriteLine($"Pre-warmed browser {pooledBrowser.Id}, pool size: {_currentPoolSize}/{_maxPoolSize}",
+ "BROWSER_POOL");
+ }
+ finally
+ {
+ _creationLock.Release();
+ }
+ }
+ catch (Exception ex)
+ {
+ Trace.WriteLine($"Failed to pre-warm browser: {ex.Message}", "BROWSER_POOL");
+ }
+ }
+
+ private static readonly Lock _instanceLock = new();
+
+ ///
+ /// Maximum time to wait for a browser from the pool (5 minutes)
+ ///
+ private static readonly TimeSpan CheckoutTimeout = TimeSpan.FromMinutes(5);
+ private static BrowserPool? _instance;
+
+ private readonly ConcurrentQueue _availableBrowsers = new();
+ private readonly ConcurrentDictionary _checkedOutBrowsers = new();
+ private readonly SemaphoreSlim _poolSemaphore;
+ private readonly SemaphoreSlim _creationLock = new(1, 1);
+ private readonly BrowserTypeLaunchOptions _launchOptions;
+ private readonly IBrowserType _browserType;
+ private readonly int _maxPoolSize;
+ private int _currentPoolSize;
+ private bool _disposed;
+ private bool _preWarmStarted;
}
///
@@ -235,16 +336,6 @@ public async ValueTask DisposeAsync()
///
public sealed class PooledBrowser : IAsyncDisposable
{
- private readonly BrowserPool _pool;
- private bool _disposed;
-
- public Guid Id { get; } = Guid.NewGuid();
- public IBrowser Browser { get; }
- public DateTime CreatedAt { get; } = DateTime.UtcNow;
- public DateTime? CheckedOutAt { get; private set; }
- public DateTime? ReturnedAt { get; private set; }
- public int UseCount { get; private set; }
-
internal PooledBrowser(IBrowser browser, BrowserPool pool)
{
Browser = browser;
@@ -254,21 +345,32 @@ internal PooledBrowser(IBrowser browser, BrowserPool pool)
browser.Disconnected += OnBrowserDisconnected;
}
- private async void OnBrowserDisconnected(object? sender, IBrowser browser)
- {
- Trace.WriteLine($"Browser {Id} disconnected unexpectedly", "BROWSER_POOL");
- await _pool.ReportFailedAsync(this).ConfigureAwait(false);
- }
+ public Guid Id { get; } = Guid.NewGuid();
+ public IBrowser Browser { get; }
+ public DateTime CreatedAt { get; } = DateTime.UtcNow;
+ public DateTime? CheckedOutAt { get; private set; }
+ public DateTime? ReturnedAt { get; private set; }
+ public int UseCount { get; private set; }
- internal void MarkCheckedOut()
+ public async ValueTask DisposeAsync()
{
- CheckedOutAt = DateTime.UtcNow;
- UseCount++;
- }
+ if (_disposed)
+ {
+ return;
+ }
- internal void MarkReturned()
- {
- ReturnedAt = DateTime.UtcNow;
+ _disposed = true;
+
+ Browser.Disconnected -= OnBrowserDisconnected;
+
+ try
+ {
+ await Browser.CloseAsync().ConfigureAwait(false);
+ }
+ catch
+ {
+ // Ignore errors during browser close
+ }
}
///
@@ -309,21 +411,23 @@ public async Task CloseAllContextsAsync()
}
}
- public async ValueTask DisposeAsync()
+ private async void OnBrowserDisconnected(object? sender, IBrowser browser)
{
- if (_disposed) return;
-
- _disposed = true;
+ Trace.WriteLine($"Browser {Id} disconnected unexpectedly", "BROWSER_POOL");
+ await _pool.ReportFailedAsync(this).ConfigureAwait(false);
+ }
- Browser.Disconnected -= OnBrowserDisconnected;
+ internal void MarkCheckedOut()
+ {
+ CheckedOutAt = DateTime.UtcNow;
+ UseCount++;
+ }
- try
- {
- await Browser.CloseAsync().ConfigureAwait(false);
- }
- catch
- {
- // Ignore errors during browser close
- }
+ internal void MarkReturned()
+ {
+ ReturnedAt = DateTime.UtcNow;
}
-}
+
+ private readonly BrowserPool _pool;
+ private bool _disposed;
+}
\ No newline at end of file
diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/GeoBlazorTestClass.cs b/test/dymaptic.GeoBlazor.Core.Test.Automation/GeoBlazorTestClass.cs
index 05d0df3f4..f742d214b 100644
--- a/test/dymaptic.GeoBlazor.Core.Test.Automation/GeoBlazorTestClass.cs
+++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/GeoBlazorTestClass.cs
@@ -9,6 +9,10 @@ public abstract class GeoBlazorTestClass : PlaywrightTest
{
private IBrowserContext Context { get; set; } = null!;
+ private PageGotoOptions PageGotoOptions => new() { WaitUntil = WaitUntilState.Commit, Timeout = 60_000 };
+
+ private LocatorAssertionsToBeVisibleOptions VisibleOptions => new() { Timeout = 90_000 };
+
[TestInitialize]
public Task TestSetup()
{
@@ -68,13 +72,20 @@ protected async Task RunTestImplementation(string testName, int retries = 0)
Trace.WriteLine($"Navigating to {testUrl}", "TEST");
- await page.GotoAsync(testUrl,
- _pageGotoOptions);
+ await page.GotoAsync(testUrl, PageGotoOptions);
Trace.WriteLine($"Page loaded for {testName}", "TEST");
+
+ // Skip section toggle click if already expanded (optimization)
ILocator sectionToggle = page.GetByTestId("section-toggle");
- await sectionToggle.ClickAsync(_clickOptions);
+ var isExpanded = await sectionToggle.GetAttributeAsync("aria-expanded");
+
+ if (isExpanded != "true")
+ {
+ await sectionToggle.ClickAsync();
+ }
+
ILocator testBtn = page.GetByText("Run Test");
- await testBtn.ClickAsync(_clickOptions);
+ await testBtn.ClickAsync();
ILocator passedSpan = page.GetByTestId("passed");
ILocator inconclusiveSpan = page.GetByTestId("inconclusive");
@@ -85,7 +96,7 @@ await page.GotoAsync(testUrl,
}
else
{
- await Expect(passedSpan).ToBeVisibleAsync(_visibleOptions);
+ await Expect(passedSpan).ToBeVisibleAsync(VisibleOptions);
await Expect(passedSpan).ToHaveTextAsync("Passed: 1");
Trace.WriteLine($"{testName} Passed", "TEST");
}
@@ -117,6 +128,11 @@ await page.GotoAsync(testUrl,
Assert.Fail($"{testName} Exceeded the maximum number of retries.");
}
+ // Exponential backoff: 1s, 2s between retries
+ var backoffMs = 1000 * (retries + 1);
+ Trace.WriteLine($"Retrying {testName} in {backoffMs}ms (attempt {retries + 2}/3)", "TEST");
+ await Task.Delay(backoffMs);
+
await RunTestImplementation(testName, retries + 1);
}
finally
@@ -147,6 +163,7 @@ private async Task Setup(int retries)
// Create context on the pooled browser
Context = await NewContextAsync(ContextOptions()).ConfigureAwait(false);
+ Context.SetDefaultTimeout(60_000);
}
catch (Exception e)
{
@@ -244,15 +261,6 @@ private void HandlePageError(object? pageObject, string message)
]
};
- private readonly PageGotoOptions _pageGotoOptions = new()
- {
- WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 60_000
- };
-
- private readonly LocatorClickOptions _clickOptions = new() { Timeout = 120_000 };
-
- private readonly LocatorAssertionsToBeVisibleOptions _visibleOptions = new() { Timeout = 120_000 };
-
private readonly Dictionary> _consoleMessages = [];
private readonly Dictionary> _errorMessages = [];
private PooledBrowser? _pooledBrowser;
diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/TestConfig.cs b/test/dymaptic.GeoBlazor.Core.Test.Automation/TestConfig.cs
index 3233ed911..94d9aa22b 100644
--- a/test/dymaptic.GeoBlazor.Core.Test.Automation/TestConfig.cs
+++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/TestConfig.cs
@@ -24,12 +24,19 @@ public class TestConfig
///
/// Maximum number of concurrent browser instances in the pool.
/// Configurable via BROWSER_POOL_SIZE environment variable.
- /// Default: 2 for CI environments, 4 for local development.
+ /// Default: 4 for CI environments, 8 for local development.
///
- public static int BrowserPoolSize { get; private set; } = 2;
+ public static int BrowserPoolSize { get; private set; } = 4;
- private static string ComposeFilePath => Path.Combine(_projectFolder,
- _proAvailable && !CoreOnly ? "docker-compose-pro.yml" : "docker-compose-core.yml");
+ ///
+ /// Indicates whether the tests are running in a CI environment.
+ /// Used for timeout and pool size configuration.
+ ///
+ public static bool IsCI { get; private set; }
+
+ private static string ComposeFilePath => _proAvailable && !CoreOnly ? ProComposeFilePath : CoreComposeFilePath;
+ private static string CoreComposeFilePath => Path.Combine(_projectFolder, "docker-compose-core.yml");
+ private static string ProComposeFilePath => Path.Combine(_projectFolder, "docker-compose-pro.yml");
private static string TestAppPath => _proAvailable
? Path.GetFullPath(Path.Combine(_projectFolder, "..", "..", "..", "test",
"dymaptic.GeoBlazor.Pro.Test.WebApp",
@@ -43,6 +50,12 @@ public class TestConfig
private static string CoverageFolderPath => Path.Combine(_projectFolder, "coverage");
private static string CoverageFilePath =>
Path.Combine(CoverageFolderPath, $"coverage.{_coverageFileVersion}.{_coverageFormat}");
+ private static string UnitCoverageFolderPath => Path.Combine(_projectFolder, "unit-coverage");
+ private static string UnitCoverageFilePath =>
+ Path.Combine(UnitCoverageFolderPath, $"coverage.{_coverageFileVersion}.{_coverageFormat}");
+ private static string SgenCoverageFolderPath => Path.Combine(_projectFolder, "sgen-coverage");
+ private static string SgenCoverageFilePath =>
+ Path.Combine(SgenCoverageFolderPath, $"coverage.{_coverageFileVersion}.{_coverageFormat}");
private static string CoreRepoRoot => Path.GetFullPath(Path.Combine(_projectFolder, "..", ".."));
private static string ProRepoRoot => Path.GetFullPath(Path.Combine(_projectFolder, "..", "..", ".."));
private static string CoreProjectPath =>
@@ -57,27 +70,34 @@ public static async Task AssemblyInitialize(TestContext testContext)
Trace.Listeners.Add(new StringBuilderTraceListener(logBuilder));
Trace.AutoFlush = true;
+ SetupConfiguration();
+
// kill old running test apps and containers
- await StopContainer();
- await StopTestApp();
+ Task[] cleanupTasks =
+ [
+ StopContainer(CoreComposeFilePath),
+ StopContainer(ProComposeFilePath),
+ StopTestApp()
+ ];
- SetupConfiguration();
+ await Task.WhenAll(cleanupTasks);
- if (_cover)
- {
- await InstallCodeCoverageTools();
- }
+ Task[] setupTasks =
+ [
+ InstallCodeCoverageTools(),
+ EnsurePlaywrightBrowsersAreInstalled()
+ ];
- await EnsurePlaywrightBrowsersAreInstalled();
+ await Task.WhenAll(setupTasks);
- if (_useContainer)
- {
- await StartContainer();
- }
- else
- {
- await StartTestApp();
- }
+ Task[] runTasks =
+ [
+ RunUnitTests(),
+ RunSourceGeneratorTests(),
+ LaunchWebTests()
+ ];
+
+ await Task.WhenAll(runTasks);
}
[AssemblyCleanup]
@@ -99,7 +119,12 @@ public static async Task AssemblyCleanup()
if (_useContainer)
{
- await StopContainer();
+ await StopContainer(ComposeFilePath);
+
+ if (_cover)
+ {
+ CopyCoverageFromContainer();
+ }
}
else
{
@@ -180,11 +205,11 @@ private static void SetupConfiguration()
_useContainer = _configuration.GetValue("USE_CONTAINER", false);
- // Configure browser pool size - smaller for CI, larger for local development
- _isCI = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"));
- var defaultPoolSize = _isCI ? 2 : 4;
+ // Configure browser pool size - larger pools improve parallelism
+ IsCI = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"));
+ var defaultPoolSize = IsCI ? 4 : 8; // Doubled from 2/4 to 4/8 for better parallelization
BrowserPoolSize = _configuration.GetValue("BROWSER_POOL_SIZE", defaultPoolSize);
- Trace.WriteLine($"Browser pool size set to: {BrowserPoolSize} (CI: {_isCI})", "TEST_SETUP");
+ Trace.WriteLine($"Browser pool size set to: {BrowserPoolSize} (CI: {IsCI})", "TEST_SETUP");
_cover = _configuration.GetValue("COVER", false)
@@ -218,8 +243,123 @@ private static void SetupConfiguration()
_targetFramework ??= _configuration.GetValue("TARGET_FRAMEWORK", "net10.0");
}
+ private static async Task RunUnitTests()
+ {
+ var unitTestPath = Path.Combine(_projectFolder, "..",
+ "dymaptic.GeoBlazor.Core.Test.Unit",
+ "dymaptic.GeoBlazor.Core.Test.Unit.csproj");
+ var cmdLineApp = "dotnet";
+
+ string[] args =
+ [
+ "test",
+ "--project",
+ unitTestPath,
+ "-c",
+ "Release",
+ "--output",
+ "Detailed"
+ ];
+
+ if (_cover)
+ {
+ Directory.CreateDirectory(UnitCoverageFolderPath);
+ cmdLineApp = "dotnet-coverage";
+ var dotnetCommand = $"dotnet {string.Join(" ", args)}";
+
+ args =
+ [
+ "collect",
+ "-o", UnitCoverageFilePath,
+ "-f", _coverageFormat,
+ "--include-files", "**/dymaptic.GeoBlazor.Core.dll",
+ "--include-files", "**/dymaptic.GeoBlazor.Pro.dll",
+ dotnetCommand
+ ];
+ }
+
+ var result = await Cli.Wrap(cmdLineApp)
+ .WithArguments(args)
+ .WithStandardOutputPipe(PipeTarget.ToDelegate(output => Trace.WriteLine(output, "UNIT_TEST")))
+ .WithStandardErrorPipe(PipeTarget.ToDelegate(output => Trace.WriteLine(output, "UNIT_TEST_ERROR")))
+ .ExecuteAsync();
+
+ if (result.ExitCode != 0)
+ {
+ throw new ProcessExitedException($"Unit test process exited with code {result.ExitCode}");
+ }
+ }
+
+ private static async Task RunSourceGeneratorTests()
+ {
+ var sgenFilePath = Path.Combine(_projectFolder, "..",
+ "dymaptic.GeoBlazor.Core.SourceGenerator.Tests",
+ "dymaptic.GeoBlazor.Core.SourceGenerator.Tests.csproj");
+ var cmdLineApp = "dotnet";
+
+ string[] args =
+ [
+ "test",
+ "--project",
+ sgenFilePath,
+ "-c",
+ "Release",
+ "--output",
+ "Detailed"
+ ];
+
+ if (_cover)
+ {
+ Directory.CreateDirectory(SgenCoverageFolderPath);
+ cmdLineApp = "dotnet-coverage";
+ var dotnetCommand = $"dotnet {string.Join(" ", args)}";
+
+ args =
+ [
+ "collect",
+ "-o", SgenCoverageFilePath,
+ "-f", _coverageFormat,
+ "--include-files", "**/dymaptic.GeoBlazor.Core.dll",
+ "--include-files", "**/dymaptic.GeoBlazor.Pro.dll",
+ "--include-files", "**/dymaptic.GeoBlazor.Core.SourceGenerator.dll",
+ "--include-files", "**/dymaptic.GeoBlazor.Pro.SourceGenerator.dll",
+ "--include-files", "**/dymaptic.GeoBlazor.Core.SourceGenerator.Shared.dll",
+ "--include-files", "**/dymaptic.GeoBlazor.Core.Analyzers.dll",
+ dotnetCommand
+ ];
+ }
+
+ var result = await Cli.Wrap(cmdLineApp)
+ .WithArguments(args)
+ .WithStandardOutputPipe(PipeTarget.ToDelegate(output => Trace.WriteLine(output, "SGEN_TEST")))
+ .WithStandardErrorPipe(PipeTarget.ToDelegate(output => Trace.WriteLine(output, "SGEN_TEST_ERROR")))
+ .ExecuteAsync();
+
+ if (result.ExitCode != 0)
+ {
+ throw new ProcessExitedException($"Source Generator test process exited with code {result.ExitCode}");
+ }
+ }
+
+ private static async Task LaunchWebTests()
+ {
+ if (_useContainer)
+ {
+ await StartContainer();
+ }
+ else
+ {
+ await StartTestApp();
+ }
+ }
+
private static async Task InstallCodeCoverageTools()
{
+ if (!_cover)
+ {
+ return;
+ }
+
await Cli.Wrap("dotnet")
.WithArguments([
"tool",
@@ -318,12 +458,6 @@ private static async Task StartContainer()
.ExecuteAsync(cts.Token, gracefulCts.Token);
_testProcessId = commandTask.ProcessId;
- var result = await commandTask;
-
- if (result.ExitCode != 0)
- {
- throw new ProcessExitedException($"Container failed to start: {result.ExitCode}");
- }
await WaitForHttpResponse();
}
@@ -334,11 +468,15 @@ private static async Task StartTestApp()
string[] args =
[
- "run", "--project", $"\"{TestAppPath}\"",
+ "run", "--project", TestAppPath,
"--urls", $"{TestAppUrl};{TestAppHttpUrl}",
"--", "-c", "Release",
- "/p:GenerateXmlComments=false", "/p:GeneratePackage=false",
- "/p:DebugSymbols=true", "/p:DebugType=portable"
+ "/p:GenerateXmlComments=false",
+ "/p:GeneratePackage=false",
+ "/p:GenerateDocs=false",
+ "/p:DebugSymbols=true",
+ "/p:DebugType=portable",
+ "/p:UsePackageReference=false"
];
if (_cover)
@@ -354,6 +492,8 @@ private static async Task StartTestApp()
"collect",
"-o", CoverageFilePath,
"-f", _coverageFormat,
+ "--include-files", "**/dymaptic.GeoBlazor.Core.dll",
+ "--include-files", "**/dymaptic.GeoBlazor.Pro.dll",
dotnetCommand
];
}
@@ -372,7 +512,7 @@ private static async Task StartTestApp()
await WaitForHttpResponse();
}
- private static async Task StopContainer()
+ private static async Task StopContainer(string composeFilePath)
{
// If coverage is enabled, gracefully shutdown dotnet-coverage before stopping the container
if (_cover)
@@ -382,36 +522,19 @@ private static async Task StopContainer()
try
{
- Trace.WriteLine($"Stopping container with: docker compose -f {ComposeFilePath} down", "TEST_CLEANUP");
+ Trace.WriteLine($"Stopping container with: docker compose -f {composeFilePath} down", "TEST_CLEANUP");
await Cli.Wrap("docker")
- .WithArguments($"compose -f \"{ComposeFilePath}\" down")
+ .WithArguments($"compose -f \"{composeFilePath}\" down")
.WithValidation(CommandResultValidation.None)
+ .WithStandardOutputPipe(PipeTarget.ToDelegate(line => Trace.WriteLine(line, "TEST_CONTAINER_CLEANUP")))
.ExecuteAsync(cts.Token);
- Trace.WriteLine("Container stopped successfully", "TEST_CLEANUP");
}
catch
{
// ignore, these just clutter the output
}
- // If coverage was enabled, copy the coverage file from the volume mount directory
- if (_cover)
- {
- var containerCoverageFile = Path.Combine(_projectFolder, "coverage", "coverage.xml");
- var targetCoverageFile = Path.Combine(_projectFolder, $"coverage.{_coverageFormat}");
-
- if (File.Exists(containerCoverageFile))
- {
- File.Copy(containerCoverageFile, targetCoverageFile, true);
- Trace.WriteLine($"Coverage file copied from container: {targetCoverageFile}", "TEST_CLEANUP");
- }
- else
- {
- Trace.WriteLine($"Container coverage file not found: {containerCoverageFile}", "TEST_CLEANUP");
- }
- }
-
await KillProcessesByTestPorts();
}
@@ -436,9 +559,9 @@ private static async Task ShutdownCoverageCollection()
await Cli.Wrap("docker")
.WithArguments($"exec {containerName} /tools/dotnet-coverage shutdown geoblazor-coverage")
.WithStandardOutputPipe(PipeTarget.ToDelegate(line =>
- Trace.WriteLine(line, "CODE_COVERAGE")))
+ Trace.WriteLine(line, "CODE_COVERAGE_SHUTDOWN")))
.WithStandardErrorPipe(PipeTarget.ToDelegate(line =>
- Trace.WriteLine(line, "CODE_COVERAGE_ERROR")))
+ Trace.WriteLine(line, "CODE_COVERAGE_SHUTDOWN_ERROR")))
.ExecuteAsync();
// Give time for coverage file to be written
@@ -451,6 +574,23 @@ await Cli.Wrap("docker")
}
}
+ private static void CopyCoverageFromContainer()
+ {
+ // If coverage was enabled, copy the coverage file from the volume mount directory
+ var containerCoverageFile = Path.Combine(_projectFolder, "coverage", "coverage.xml");
+ var targetCoverageFile = Path.Combine(_projectFolder, $"coverage.{_coverageFormat}");
+
+ if (File.Exists(containerCoverageFile))
+ {
+ File.Copy(containerCoverageFile, targetCoverageFile, true);
+ Trace.WriteLine($"Coverage file copied from container: {targetCoverageFile}", "TEST_CLEANUP");
+ }
+ else
+ {
+ Trace.WriteLine($"Container coverage file not found: {containerCoverageFile}", "TEST_CLEANUP");
+ }
+ }
+
private static async Task WaitForHttpResponse()
{
// Configure HttpClient to ignore SSL certificate errors (for self-signed certs in Docker)
@@ -569,13 +709,23 @@ private static async Task GenerateCoverageReport()
try
{
- Trace.WriteLine("Generating coverage report...", "CODE_COVERAGE");
+ Trace.WriteLine("Generating coverage report...", "CODE_COVERAGE_REPORT");
- List assemblyFilters = CoreOnly
- ? ["+dymaptic.GeoBlazor.Core.dll"]
+ List assemblyFilters =
+ [
+ "+dymaptic.GeoBlazor.Core.dll",
+ "+dymaptic.GeoBlazor.Core.SourceGenerator.dll",
+ "+dymaptic.GeoBlazor.Core.SourceGenerator.Shared.dll",
+ "+dymaptic.GeoBlazor.Core.Analyzers.dll",
+ "+dymaptic.GeoBlazor.Pro.dll",
+ "+dymaptic.GeoBlazor.Pro.SourceGenerator.dll"
+ ];
+
+ assemblyFilters = CoreOnly
+ ? assemblyFilters.Where(a => a.Contains("Core")).ToList()
: ProOnly
- ? ["+dymaptic.GeoBlazor.Pro.dll"]
- : ["+dymaptic.GeoBlazor.Core.dll", "+dymaptic.GeoBlazor.Pro.dll"];
+ ? assemblyFilters.Where(a => a.Contains("Pro")).ToList()
+ : assemblyFilters;
List sourceDirs = CoreOnly
? [CoreProjectPath]
@@ -585,7 +735,7 @@ private static async Task GenerateCoverageReport()
List args =
[
- $"-reports:{CoverageFilePath}",
+ $"-reports:{CoverageFilePath};{UnitCoverageFilePath};{SgenCoverageFilePath}",
$"-targetdir:{reportDir}",
"-reporttypes:Html;HtmlSummary;TextSummary;Badges",
@@ -608,35 +758,20 @@ await Cli.Wrap("reportgenerator")
.WithValidation(CommandResultValidation.None)
.ExecuteAsync();
- var indexPath = Path.Combine(reportDir, "index.html");
+ string textSummaryPath = Path.Combine(reportDir, "Summary.txt");
+ string webReportPath = Path.Combine(reportDir, "index.html");
- if (File.Exists(indexPath))
+ if (File.Exists(textSummaryPath))
{
- Trace.WriteLine($"Coverage report generated: {indexPath}", "CODE_COVERAGE");
-
- // Open report in browser for local development (not CI)
- if (!_isCI)
- {
- try
- {
- OpenInBrowser(indexPath);
- Trace.WriteLine("Coverage report opened in browser", "CODE_COVERAGE");
- }
- catch (Exception ex)
- {
- Trace.WriteLine($"Failed to open browser: {ex.Message}", "CODE_COVERAGE");
- }
- }
- }
- else
- {
- Trace.WriteLine("Coverage report index.html was not generated", "CODE_COVERAGE_ERROR");
+ Trace.WriteLine(await File.ReadAllTextAsync(textSummaryPath),
+ "CODE_COVERAGE_SUCCESS");
+ Trace.WriteLine($"Full report at [Coverage Report]({webReportPath})", "CODE_COVERAGE_SUCCESS");
}
// copy the badge image to the repo root
var lineBadgePath = Path.Combine(reportDir, "badge_linecoverage.svg");
var methodBadgePath = Path.Combine(reportDir, "badge_methodcoverage.svg");
- var fullMethodBadgePath = Path.Combine(_projectFolder, "badge_fullmethodcoverage.svg");
+ var fullMethodBadgePath = Path.Combine(reportDir, "badge_fullmethodcoverage.svg");
if (!ProOnly)
{
@@ -664,19 +799,6 @@ await Cli.Wrap("reportgenerator")
}
}
- private static void OpenInBrowser(string path)
- {
- var cmdLineApp = OperatingSystem.IsWindows()
- ? "start"
- : OperatingSystem.IsMacOS()
- ? "open"
- : "xdg-open";
-
- Cli.Wrap(cmdLineApp)
- .WithArguments(path)
- .ExecuteAsync();
- }
-
private static readonly CancellationTokenSource cts = new();
private static readonly CancellationTokenSource gracefulCts = new();
private static readonly StringBuilder logBuilder = new();
@@ -694,5 +816,4 @@ private static void OpenInBrowser(string path)
private static string _coverageFormat = string.Empty;
private static string _coverageFileVersion = string.Empty;
private static string? _reportGenLicenseKey;
- private static bool _isCI;
}
\ No newline at end of file
diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-compose-core.yml b/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-compose-core.yml
index 6c44fa83e..a6b5caa81 100644
--- a/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-compose-core.yml
+++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-compose-core.yml
@@ -10,8 +10,6 @@ services:
GEOBLAZOR_LICENSE_KEY: ${GEOBLAZOR_CORE_LICENSE_KEY}
HTTP_PORT: ${HTTP_PORT}
HTTPS_PORT: ${HTTPS_PORT}
- COVERAGE_FORMAT: ${COVERAGE_FORMAT}
- COVERAGE_FILE_VERSION: ${COVERAGE_FILE_VERSION}
WFS_SERVERS: |-
"WFSServers": [
{
@@ -27,7 +25,7 @@ services:
environment:
- ASPNETCORE_ENVIRONMENT=Production
- COVERAGE_ENABLED=${COVERAGE_ENABLED:-false}
- - COVERAGE_OUTPUT=/coverage/coverage.${COVERAGE_FILE_VERSION}.${COVERAGE_FORMAT:-xml}
+ - COVERAGE_FORMAT=${COVERAGE_FORMAT:-xml}
ports:
- "${HTTP_PORT:-8080}:${HTTP_PORT:-8080}"
- "${HTTPS_PORT:-9443}:${HTTPS_PORT:-9443}"
diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-compose-pro.yml b/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-compose-pro.yml
index 44e77827a..94a000e5e 100644
--- a/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-compose-pro.yml
+++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-compose-pro.yml
@@ -10,8 +10,6 @@ services:
GEOBLAZOR_LICENSE_KEY: ${GEOBLAZOR_PRO_LICENSE_KEY}
HTTP_PORT: ${HTTP_PORT}
HTTPS_PORT: ${HTTPS_PORT}
- COVERAGE_FORMAT: ${COVERAGE_FORMAT}
- COVERAGE_FILE_VERSION: ${COVERAGE_FILE_VERSION}
WFS_SERVERS: |-
"WFSServers": [
{
@@ -27,7 +25,7 @@ services:
environment:
- ASPNETCORE_ENVIRONMENT=Production
- COVERAGE_ENABLED=${COVERAGE_ENABLED:-false}
- - COVERAGE_OUTPUT=/coverage/coverage.${COVERAGE_FILE_VERSION}.${COVERAGE_FORMAT:-xml}
+ - COVERAGE_FORMAT=${COVERAGE_FORMAT:-xml}
ports:
- "${HTTP_PORT:-8080}:${HTTP_PORT:-8080}"
- "${HTTPS_PORT:-9443}:${HTTPS_PORT:-9443}"
diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-entrypoint.sh b/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-entrypoint.sh
index d9e94a894..7569cef12 100644
--- a/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-entrypoint.sh
+++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-entrypoint.sh
@@ -2,6 +2,8 @@
set -e
SESSION_ID="geoblazor-coverage"
+COVERAGE_FILE_VERSION="$(date +%Y-%m-%d-%H-%M-%S)"
+COVERAGE_OUTPUT="/coverage/coverage.$COVERAGE_FILE_VERSION.$COVERAGE_FORMAT"
# Trap SIGTERM to gracefully shutdown coverage collection
_term() {
@@ -34,6 +36,8 @@ if [ "$COVERAGE_ENABLED" = "true" ]; then
--session-id "$SESSION_ID" \
-o "$COVERAGE_OUTPUT" \
-f xml \
+ --include-files "**/dymaptic.GeoBlazor.Core.dll" \
+ --include-files "**/dymaptic.GeoBlazor.Pro.dll" \
-l "$COVERAGE_OUTPUT.log" \
-ll Verbose \
-- "$@"
diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj b/test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj
index 7f1c07ff7..a3d3ab3fb 100644
--- a/test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj
+++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj
@@ -21,7 +21,6 @@
-
diff --git a/test/dymaptic.GeoBlazor.Core.Test.Unit/PlaywrightTests.cs b/test/dymaptic.GeoBlazor.Core.Test.Unit/PlaywrightTests.cs
deleted file mode 100644
index 5a02576a1..000000000
--- a/test/dymaptic.GeoBlazor.Core.Test.Unit/PlaywrightTests.cs
+++ /dev/null
@@ -1,865 +0,0 @@
-using Microsoft.Extensions.Configuration;
-using Microsoft.Playwright;
-using System.Diagnostics;
-
-
-namespace dymaptic.GeoBlazor.Core.Test.Unit;
-
-[TestClass]
-public class PlaywrightTests
-{
- ///
- /// An automated, yet still tedious process of going through each screen and clicking on things.
- /// This test will likely break on changes to the repository, so don't rely on for stability.
- ///
- ///
- /// For faster and more reliable testing, use the blazor test runner in `dymaptic.GeoBlazor.Core.Test.Blazor.Server`
- /// or `dymaptic.GeoBlazor.Core.Test.Blazor.Wasm`.
- ///
- [TestMethod]
- public async Task RunThroughScreens()
- {
- StartServer();
- string? apiKey = new ConfigurationBuilder().AddUserSecrets().Build()["ArcGISApiKey"];
-
- if (!Directory.Exists(_screenShotsFolder))
- {
- Directory.CreateDirectory(_screenShotsFolder);
- }
- else
- {
- // move current screenshots to previous folder
- FileInfo[] screenshots = new DirectoryInfo(_screenShotsFolder).GetFiles();
-
- foreach (FileInfo ssFile in screenshots)
- {
- File.Move(ssFile.FullName, Path.Combine(_screenShotsFolder, "Previous", ssFile.Name), true);
- }
- }
-
- IPlaywright playwright = await Playwright.CreateAsync()!;
- IBrowser browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = false });
- IPage page = await browser.NewPageAsync();
-
- var renderMessage = new PageWaitForConsoleMessageOptions
- {
- Predicate = m => m.Text.Contains("View Render Complete"), Timeout = 60000
- };
- await Task.Delay(1000);
- Task waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
-
- // Go to https://localhost:7255/
- await page.GotoAsync("https://localhost:7255/navigation");
- await Task.Delay(1000);
-
- if (await page.Locator("#api-key-field").IsVisibleAsync())
- {
- await page.Locator("#api-key-field").FillAsync(apiKey!);
- await page.Locator("#api-key-field").PressAsync("Enter");
- }
-
- await waitForRenderTask;
-
- // Click text=Latitude: >> input[type="number"]
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Navigation1.png"), FullPage = true
- });
-
- // Fill text=Latitude: >> input[type="number"]
- await page.Locator("text=Latitude: >> input[type=\"number\"]").FillAsync("34.023");
-
- // Press Tab
- await page.Locator("text=Latitude: >> input[type=\"number\"]").PressAsync("Tab");
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Navigation2.png"), FullPage = true
- });
-
- // Click text=Longitude: >> input[type="number"]
- await page.Locator("text=Longitude: >> input[type=\"number\"]").ClickAsync();
-
- // Fill text=Longitude: >> input[type="number"]
- await page.Locator("text=Longitude: >> input[type=\"number\"]").FillAsync("-118.905");
-
- // Press Tab
- await page.Locator("text=Longitude: >> input[type=\"number\"]").PressAsync("Tab");
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Navigation3.png"), FullPage = true
- });
-
- // Fill text=Zoom: >> input[type="number"]
- await page.Locator("text=Zoom: >> input[type=\"number\"]").FillAsync("12");
-
- // Press Tab
- await page.Locator("text=Zoom: >> input[type=\"number\"]").PressAsync("Tab");
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Navigation4.png"), FullPage = true
- });
-
- // Fill text=Rotation (deg): >> input[type="number"]
- await page.Locator("text=Rotation (deg): >> input[type=\"number\"]").FillAsync("30");
-
- // Press Tab
- await page.Locator("text=Rotation (deg): >> input[type=\"number\"]").PressAsync("Tab");
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Navigation4.png"), FullPage = true
- });
-
- Task pageLoadTask = page.WaitForURLAsync("https://localhost:7255/source-code/navigation");
-
- // Click text=Source Code
- await page.Locator("text=Source Code").ClickAsync();
- await pageLoadTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Navigation_Source.png"), FullPage = true
- });
-
- // Click text=Drawing
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- pageLoadTask = page.WaitForURLAsync("https://localhost:7255/drawing");
- await page.Locator("text=Drawing").ClickAsync();
- await pageLoadTask;
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Drawing1.png"), FullPage = true
- });
-
- // Click text=Draw a Point >> span
- await page.Locator("text=Draw a Point >> span").ClickAsync();
-
- // Click text=Longitude: Latitude: Draw >> button
- await page.Locator("text=Longitude: Latitude: Draw >> button").ClickAsync();
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Drawing2.png"), FullPage = true
- });
-
- // Click text=Longitude: >> input[type="number"]
- await page.Locator("text=Longitude: >> input[type=\"number\"]").ClickAsync();
-
- // Fill text=Longitude: >> input[type="number"]
- await page.Locator("text=Longitude: >> input[type=\"number\"]").FillAsync("-118.7");
-
- // Press Tab
- await page.Locator("text=Longitude: >> input[type=\"number\"]").PressAsync("Tab");
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Drawing3.png"), FullPage = true
- });
-
- // Press ArrowDown
- await page.Locator("text=Latitude: >> input[type=\"number\"]").PressAsync("ArrowDown");
-
- // Press ArrowDown
- await page.Locator("text=Latitude: >> input[type=\"number\"]").PressAsync("ArrowDown");
-
- // Press ArrowUp
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Drawing4.png"), FullPage = true
- });
-
- // Click text=Longitude: Latitude: Remove >> button
- await page.Locator("text=Longitude: Latitude: Remove >> button").ClickAsync();
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Drawing5.png"), FullPage = true
- });
-
- // Click text=Draw a Line
- await page.Locator("text=Draw a Line").ClickAsync();
-
- // Click button:has-text("Draw") >> nth=1
- await page.Locator("button:has-text(\"Draw\")").Nth(1).ClickAsync();
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Drawing6.png"), FullPage = true
- });
-
- // Click tr:nth-child(2) > td:nth-child(2) > input >> nth=0
- await page.Locator("tr:nth-child(2) > td:nth-child(2) > input").First.ClickAsync();
-
- // Press ArrowDown
- await page.Locator("tr:nth-child(2) > td:nth-child(2) > input").First.PressAsync("ArrowDown");
-
- // Press ArrowDown
- await page.Locator("tr:nth-child(2) > td:nth-child(2) > input").First.PressAsync("ArrowDown");
-
- // Press ArrowDown
- await page.Locator("tr:nth-child(2) > td:nth-child(2) > input").First.PressAsync("ArrowDown");
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Drawing7.png"), FullPage = true
- });
-
- // Click text=Add Pt >> nth=0
- await page.Locator("text=Add Pt").First.ClickAsync();
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Drawing8.png"), FullPage = true
- });
-
- // Click tr:nth-child(4) > td:nth-child(2) > input >> nth=0
- await page.Locator("tr:nth-child(4) > td:nth-child(2) > input").First.ClickAsync();
-
- // Press ArrowUp
- await page.Locator("tr:nth-child(4) > td:nth-child(2) > input").First.PressAsync("ArrowUp");
-
- // Press ArrowUp
- await page.Locator("tr:nth-child(4) > td:nth-child(2) > input").First.PressAsync("ArrowUp");
-
- // Press ArrowUp
- await page.Locator("tr:nth-child(4) > td:nth-child(2) > input").First.PressAsync("ArrowUp");
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Drawing9.png"), FullPage = true
- });
-
- // Click text=Remove >> nth=0
- await page.Locator("text=Remove").First.ClickAsync();
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Drawing10.png"), FullPage = true
- });
-
- // Click text=Draw a Polygon
- await page.Locator("text=Draw a Polygon").ClickAsync();
-
- // Click button:has-text("Draw") >> nth=2
- await page.Locator("button:has-text(\"Draw\")").Nth(1).ClickAsync();
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Drawing11.png"), FullPage = true
- });
-
- // Click text=Add Pt >> nth=1
- await page.Locator("text=Add Pt").Nth(1).ClickAsync();
-
- // Click tr:nth-child(6) > td:nth-child(2) > input
- await page.Locator("tr:nth-child(6) > td:nth-child(2) > input").ClickAsync();
-
- // Press ArrowUp
- await page.Locator("tr:nth-child(6) > td:nth-child(2) > input").PressAsync("ArrowUp");
-
- // Press ArrowUp
- await page.Locator("tr:nth-child(6) > td:nth-child(2) > input").PressAsync("ArrowUp");
-
- // Press ArrowUp
- await page.Locator("tr:nth-child(6) > td:nth-child(2) > input").PressAsync("ArrowUp");
-
- // Press ArrowUp
- await page.Locator("tr:nth-child(6) > td:nth-child(2) > input").PressAsync("ArrowUp");
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Drawing12.png"), FullPage = true
- });
-
- // Click text=Remove Pt >> nth=1
- await page.Locator("text=Remove Pt").Nth(1).ClickAsync();
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Drawing13.png"), FullPage = true
- });
-
- // Click text=Remove >> nth=1
- await page.Locator("text=Remove").Nth(1).ClickAsync();
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Drawing14.png"), FullPage = true
- });
-
- // Click text=Source Code
- pageLoadTask = page.WaitForURLAsync("https://localhost:7255/source-code/drawing");
- await page.Locator("text=Source Code").ClickAsync();
- await pageLoadTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Drawing_Source.png"), FullPage = true
- });
-
- // Click text=Scene >> nth=0
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- pageLoadTask = page.WaitForURLAsync("https://localhost:7255/scene");
- await page.Locator("text=Scene").First.ClickAsync();
- await pageLoadTask;
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Scene.png"), FullPage = true
- });
-
- // Click text=Widgets
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- pageLoadTask = page.WaitForURLAsync("https://localhost:7255/widgets");
- await page.Locator("text=Widgets").ClickAsync();
- await pageLoadTask;
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Widgets1.png"), FullPage = true
- });
-
- // Check text=Locator: >> input[type="checkbox"]
- await page.Locator("text=Locator: >> input[type=\"checkbox\"]").CheckAsync();
- await Task.Delay(1000);
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Widgets2.png"), FullPage = true
- });
-
- // Check text=Search: >> input[type="checkbox"]
- await page.Locator("text=Search: >> input[type=\"checkbox\"]").CheckAsync();
- await Task.Delay(1000);
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Widgets3.png"), FullPage = true
- });
-
- // Check text=Basemap Toggle: >> input[type="checkbox"]
- await page.Locator("text=Basemap Toggle: >> input[type=\"checkbox\"]").CheckAsync();
- await Task.Delay(1000);
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Widgets4.png"), FullPage = true
- });
-
- // Check text=Basemap Gallery: >> input[type="checkbox"]
- await page.Locator("text=Basemap Gallery: >> input[type=\"checkbox\"]").CheckAsync();
- await Task.Delay(1000);
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Widgets5.png"), FullPage = true
- });
-
- // Check text=Legend: >> input[type="checkbox"]
- await page.Locator("text=Legend: >> input[type=\"checkbox\"]").CheckAsync();
- await Task.Delay(1000);
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Widgets6.png"), FullPage = true
- });
-
- // Check text=Scale Bar: >> input[type="checkbox"]
- await page.Locator("text=Scale Bar: >> input[type=\"checkbox\"]").CheckAsync();
- await Task.Delay(1000);
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Widgets7.png"), FullPage = true
- });
-
- // Uncheck text=Legend: >> input[type="checkbox"]
- await page.Locator("text=Legend: >> input[type=\"checkbox\"]").UncheckAsync();
- await Task.Delay(1000);
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Widgets8.png"), FullPage = true
- });
-
- // Check text=Legend: >> input[type="checkbox"]
- await page.Locator("text=Legend: >> input[type=\"checkbox\"]").CheckAsync();
- await Task.Delay(1000);
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Widgets9.png"), FullPage = true
- });
-
- // Click [aria-label="Find my location"]
- await page.Locator("[aria-label=\"Find my location\"]").ClickAsync();
- await Task.Delay(1000);
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Widgets10.png"), FullPage = true
- });
-
- // Click [placeholder="Find address or place"]
- await page.Locator("[placeholder=\"Find address or place\"]").ClickAsync();
- await Task.Delay(1000);
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Widgets11.png"), FullPage = true
- });
-
- // Fill [placeholder="Find address or place"]
- await page.Locator("[placeholder=\"Find address or place\"]")
- .FillAsync("1600 Pennsylvania Ave, Washington, DC");
-
- // Press Enter
- await page.Locator("[placeholder=\"Find address or place\"]").PressAsync("Enter");
- await Task.Delay(1000);
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Widgets12.png"), FullPage = true
- });
-
- // Click [aria-label="Close"]
- await page.Locator("[aria-label=\"Close\"]").ClickAsync();
- await Task.Delay(1000);
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Widgets13.png"), FullPage = true
- });
-
- // Click li[role="menuitem"]:has-text("Imagery Hybrid")
- await page.Locator("li[role=\"menuitem\"]:has-text(\"Imagery Hybrid\")").ClickAsync();
- await Task.Delay(1000);
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Widgets14.png"), FullPage = true
- });
-
- // Click .esri-basemap-gallery__item-thumbnail >> nth=0
- await page.Locator(".esri-basemap-gallery__item-thumbnail").First.ClickAsync();
- await Task.Delay(1000);
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Widgets15.png"), FullPage = true
- });
-
- // Click text=Source Code
- pageLoadTask = page.WaitForURLAsync("https://localhost:7255/source-code/widgets");
- await page.Locator("text=Source Code").ClickAsync();
- await pageLoadTask;
- await Task.Delay(1000);
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Widgets_Source.png"), FullPage = true
- });
-
- // Click text=Basemaps
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- pageLoadTask = page.WaitForURLAsync("https://localhost:7255/basemaps");
- await page.Locator("text=Basemaps").ClickAsync();
- await pageLoadTask;
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "BaseMaps1.png"), FullPage = true
- });
-
- // Check text=From Portal Id >> input[name="basemap-type"]
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- await page.Locator("text=From Portal Id >> input[name=\"basemap-type\"]").CheckAsync();
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "BaseMaps2.png"), FullPage = true
- });
-
- // Check text=From Tile Layers >> input[name="basemap-type"]
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- await page.Locator("text=From Tile Layers >> input[name=\"basemap-type\"]").CheckAsync();
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "BaseMaps3.png"), FullPage = true
- });
-
- // Click text=Source Code
- pageLoadTask = page.WaitForURLAsync("https://localhost:7255/source-code/basemaps");
- await page.Locator("text=Source Code").ClickAsync();
- await pageLoadTask;
- await Task.Delay(1000);
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "BaseMaps_Source.png"), FullPage = true
- });
-
- // Click text=Feature Layers
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- pageLoadTask = page.WaitForURLAsync("https://localhost:7255/feature-layers");
- await page.Locator("text=Feature Layers").ClickAsync();
- await pageLoadTask;
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "FeatureLayers1.png"), FullPage = true
- });
-
- // Check text=Show Trailheads Points Layer: >> input[name="points"]
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- await page.Locator("text=Show Trailheads Points Layer: >> input[name=\"points\"]").CheckAsync();
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "FeatureLayers2.png"), FullPage = true
- });
-
- // Check text=Show Trailheads Lines Layer: >> input[name="points"]
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- await page.Locator("text=Show Trailheads Lines Layer: >> input[name=\"points\"]").CheckAsync();
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "FeatureLayers3.png"), FullPage = true
- });
-
- // Check text=Show Trailheads Lines With Elevation Style Renderer: >> input[name="points"]
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
-
- await page.Locator("text=Show Trailheads Lines With Elevation Style Renderer: >> input[name=\"points\"]")
- .CheckAsync();
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "FeatureLayers4.png"), FullPage = true
- });
-
- // Check text=Show Trailheads Lines With Bike Trails Styled: >> input[name="points"]
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
-
- await page.Locator("text=Show Trailheads Lines With Bike Trails Styled: >> input[name=\"points\"]")
- .CheckAsync();
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "FeatureLayers5.png"), FullPage = true
- });
-
- // Check text=Show Trailheads Polygons Layer: >> input[name="points"]
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- await page.Locator("text=Show Trailheads Polygons Layer: >> input[name=\"points\"]").CheckAsync();
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "FeatureLayers6.png"), FullPage = true
- });
-
- // Click text=Vector Layer
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- pageLoadTask = page.WaitForURLAsync("https://localhost:7255/vector-layer");
- await page.Locator("text=Vector Layer").ClickAsync();
- await pageLoadTask;
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "VectorLayer.png"), FullPage = true
- });
-
- // Click text=Source Code
- pageLoadTask = page.WaitForURLAsync("https://localhost:7255/source-code/vector-layer");
- await page.Locator("text=Source Code").ClickAsync();
- await pageLoadTask;
- await Task.Delay(1000);
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "VectorLayer_Source.png"), FullPage = true
- });
-
- // Click text=Web Map
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- pageLoadTask = page.WaitForURLAsync("https://localhost:7255/web-map");
- await page.Locator("text=Web Map").ClickAsync();
- await pageLoadTask;
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "WebMap1.png"), FullPage = true
- });
-
- // Click text=Web Scene
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- pageLoadTask = page.WaitForURLAsync("https://localhost:7255/web-scene");
- await page.Locator("text=Web Scene").ClickAsync();
- await pageLoadTask;
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "WebScene1.png"), FullPage = true
- });
-
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- pageLoadTask = page.WaitForURLAsync("https://localhost:7255/sql-query");
- await page.Locator("text=SQL Query").ClickAsync();
- await pageLoadTask;
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "SqlQuery1.png"), FullPage = true
- });
-
- // Select UseType = 'Residential'
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- await page.Locator("select").SelectOptionAsync(new[] { "UseType = 'Residential'" });
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "SqlQuery2.png"), FullPage = true
- });
-
- // Select UseType = 'Irrigated Farm'
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- await page.Locator("select").SelectOptionAsync(new[] { "UseType = 'Irrigated Farm'" });
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "SqlQuery3.png"), FullPage = true
- });
-
- // Select TaxRateArea = 08637
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- await page.Locator("select").SelectOptionAsync(new[] { "TaxRateArea = 08637" });
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "SqlQuery4.png"), FullPage = true
- });
-
- // Select Roll_LandValue < 1000000
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- await page.Locator("select").SelectOptionAsync(new[] { "Roll_LandValue < 1000000" });
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "SqlQuery5.png"), FullPage = true
- });
-
- // Click text=Source Code
- pageLoadTask = page.WaitForURLAsync("https://localhost:7255/source-code/sql-query");
- await page.Locator("text=Source Code").ClickAsync();
- await pageLoadTask;
- await Task.Delay(1000);
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "SqlQuery_Source.png"), FullPage = true
- });
-
- // Click text=SQL Filter Query
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- pageLoadTask = page.WaitForURLAsync("https://localhost:7255/sql-filter-query");
- await page.Locator("text=SQL Filter Query").ClickAsync();
- await pageLoadTask;
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "SqlFilterQuery1.png"), FullPage = true
- });
-
- // Select Roll_LandValue < 200000
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- await page.Locator("select").SelectOptionAsync(new[] { "Roll_LandValue < 200000" });
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "SqlFilterQuery2.png"), FullPage = true
- });
-
- // Select Bedrooms5 > 0
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- await page.Locator("select").SelectOptionAsync(new[] { "Bedrooms5 > 0" });
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "SqlFilterQuery3.png"), FullPage = true
- });
-
- // Select Roll_RealEstateExemp > 0
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- await page.Locator("select").SelectOptionAsync(new[] { "Roll_RealEstateExemp > 0" });
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "SqlFilterQuery4.png"), FullPage = true
- });
-
- // Click text=Place Selector
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- pageLoadTask = page.WaitForURLAsync("https://localhost:7255/place-selector");
- await page.Locator("text=Place Selector").ClickAsync();
- await pageLoadTask;
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "PlaceSelector1.png"), FullPage = true
- });
-
- // Select Coffee shop
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- await page.Locator("select").SelectOptionAsync(new[] { "Coffee shop" });
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "PlaceSelector2.png"), FullPage = true
- });
-
- // Click text=Routing
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- pageLoadTask = page.WaitForURLAsync("https://localhost:7255/routing");
- await page.Locator("text=Routing").ClickAsync();
- await pageLoadTask;
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Routing1.png"), FullPage = true
- });
-
- // Click canvas
- await page.Mouse.ClickAsync(441, 320);
- await Task.Delay(1000);
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Routing2.png"), FullPage = true
- });
-
- // Click canvas
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- await page.Mouse.ClickAsync(954, 348);
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "Routing3.png"), FullPage = true
- });
-
- // Click text=Service Areas
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- pageLoadTask = page.WaitForURLAsync("https://localhost:7255/service-areas");
- await page.Locator("text=Service Areas").ClickAsync();
- await pageLoadTask;
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "ServiceAreas1.png"), FullPage = true
- });
-
- // Click canvas
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- await page.Mouse.ClickAsync(505, 339);
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "ServiceAreas2.png"), FullPage = true
- });
-
- // Click canvas
- waitForRenderTask = page.WaitForConsoleMessageAsync(renderMessage);
- await page.Mouse.ClickAsync(950, 442);
- await waitForRenderTask;
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "ServiceAreas3.png"), FullPage = true
- });
-
- // Click text=Source Code
- pageLoadTask = page.WaitForURLAsync("https://localhost:7255/source-code/service-areas");
- await page.Locator("text=Source Code").ClickAsync();
- await pageLoadTask;
- await Task.Delay(1000);
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "ServiceAreas_Source.png"), FullPage = true
- });
-
- // Click text=About
- pageLoadTask = page.WaitForURLAsync("https://localhost:7255/about");
- await page.Locator("text=Home").ClickAsync();
- await pageLoadTask;
- await Task.Delay(1000);
-
- await page.ScreenshotAsync(new PageScreenshotOptions
- {
- Path = Path.Combine(_screenShotsFolder, "About.png"), FullPage = true
- });
-
- StopServer();
- }
-
- private static void StartServer()
- {
- var processStartInfo = new ProcessStartInfo("dotnet",
- "run --project ../../../../../samples/dymaptic.GeoBlazor.Core.Sample.Server/dymaptic.GeoBlazor.Core.Sample.Server.csproj --no-build")
- {
- UseShellExecute = true
- };
-
- _serverProcess = Process.Start(processStartInfo);
- Assert.IsNotNull(_serverProcess);
- }
-
- private static void StopServer()
- {
- if (_serverProcess is not null && !_serverProcess.HasExited)
- {
- _serverProcess.CloseMainWindow();
- _serverProcess.Kill();
- }
-
- _serverProcess = null;
- }
-
- private static Process? _serverProcess;
- private readonly string _screenShotsFolder = "../../../ScreenShots";
-}
\ No newline at end of file
diff --git a/test/dymaptic.GeoBlazor.Core.Test.Unit/SerializationUnitTests.cs b/test/dymaptic.GeoBlazor.Core.Test.Unit/SerializationUnitTests.cs
index 41b33f2b8..347d6c985 100644
--- a/test/dymaptic.GeoBlazor.Core.Test.Unit/SerializationUnitTests.cs
+++ b/test/dymaptic.GeoBlazor.Core.Test.Unit/SerializationUnitTests.cs
@@ -20,15 +20,15 @@ public class SerializationUnitTests
[TestMethod]
public void SerializeGraphicToJson()
{
- var graphic = new Graphic(new Point(_random.NextDouble() * 10 + 11.0,
- _random.NextDouble() * 10 + 50.0),
+ var graphic = new Graphic(new Point((_random.NextDouble() * 10) + 11.0,
+ (_random.NextDouble() * 10) + 50.0),
new SimpleMarkerSymbol(new Outline(new MapColor("green")), new MapColor("red"), 10),
new PopupTemplate("Test", "Test Content
{testString}
{testNumber}", ["*"]),
new AttributesDictionary(
new Dictionary { { "testString", "test" }, { "testNumber", 123 } }));
var sw = Stopwatch.StartNew();
- string json = JsonSerializer.Serialize(graphic.ToSerializationRecord());
- byte[] data = Encoding.UTF8.GetBytes(json);
+ var json = JsonSerializer.Serialize(graphic.ToSerializationRecord());
+ var data = Encoding.UTF8.GetBytes(json);
sw.Stop();
Console.WriteLine($"SerializeGraphicToJson: {sw.ElapsedMilliseconds}ms");
Console.WriteLine($"Size: {data.Length} bytes");
@@ -37,38 +37,51 @@ public void SerializeGraphicToJson()
[TestMethod]
public void RoundTripSerializeGraphicToJson()
{
- var graphic = new Graphic(new Point(_random.NextDouble() * 10 + 11.0,
- _random.Next() * 10 + 50.0),
+ var graphic = new Graphic(new Point((_random.NextDouble() * 10) + 11.0,
+ (_random.Next() * 10) + 50.0),
new SimpleMarkerSymbol(new Outline(new MapColor("green")), new MapColor("red"), 10),
new PopupTemplate("Test",
- "Test Content
{testString}
{testNumber}",
- outFields: [ "*" ]),
+ "Test Content
{testString}
{testNumber}",
+ ["*"]),
new AttributesDictionary(
new Dictionary { { "testString", "test" }, { "testNumber", 123 } }));
+
JsonSerializerOptions options = new()
{
ReferenceHandler = ReferenceHandler.Preserve,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
- string json = JsonSerializer.Serialize(graphic, options);
- Graphic deserialized = JsonSerializer.Deserialize(json, options)!;
- Assert.AreEqual(((Point)graphic.Geometry!).Latitude, ((Point)deserialized.Geometry!).Latitude);
- Assert.AreEqual(((Point)graphic.Geometry!).Longitude, ((Point)deserialized.Geometry!).Longitude);
- Assert.AreEqual(((SimpleMarkerSymbol)graphic.Symbol!).Color, ((SimpleMarkerSymbol)deserialized.Symbol!).Color);
- Assert.AreEqual(((SimpleMarkerSymbol)graphic.Symbol!).Outline!.Color, ((SimpleMarkerSymbol)deserialized.Symbol!).Outline!.Color);
- Assert.AreEqual(((SimpleMarkerSymbol)graphic.Symbol!).Size, ((SimpleMarkerSymbol)deserialized.Symbol!).Size);
+ var json = JsonSerializer.Serialize(graphic, options);
+ var deserialized = JsonSerializer.Deserialize(json, options)!;
+ var graphicPoint = graphic.Geometry as Point;
+ var deserializedPoint = deserialized.Geometry as Point;
+ Assert.IsNotNull(graphicPoint);
+ Assert.IsNotNull(deserializedPoint);
+ Assert.AreEqual(graphicPoint.Latitude, deserializedPoint.Latitude);
+ Assert.AreEqual(graphicPoint.Longitude, deserializedPoint.Longitude);
+ var graphicSymbol = deserialized.Symbol as SimpleMarkerSymbol;
+ var deserializedSymbol = deserialized.Symbol as SimpleMarkerSymbol;
+ Assert.IsNotNull(graphicSymbol);
+ Assert.IsNotNull(deserializedSymbol);
+
+ Assert.AreEqual(graphicSymbol.Color, deserializedSymbol.Color,
+ $"Color {graphicSymbol.Color} does not match {deserializedSymbol.Color}");
+
+ Assert.AreEqual(graphicSymbol.Outline!.Color, deserializedSymbol.Outline!.Color,
+ $"Color {graphicSymbol.Outline} does not match {deserializedSymbol.Outline}");
+ Assert.AreEqual(graphicSymbol.Size, deserializedSymbol.Size);
Assert.AreEqual(graphic.PopupTemplate!.Title, deserialized.PopupTemplate!.Title);
Assert.AreEqual(graphic.Attributes.Count, deserialized.Attributes.Count);
Assert.AreEqual(graphic.Attributes["testString"], deserialized.Attributes["testString"]);
Assert.AreEqual(graphic.Attributes["testNumber"], deserialized.Attributes["testNumber"]);
- }
+ }
[TestMethod]
public void SerializeToProtobuf()
{
- var graphic = new Graphic(new Point(_random.NextDouble() * 10 + 11.0,
- _random.NextDouble() * 10 + 50.0),
+ var graphic = new Graphic(new Point((_random.NextDouble() * 10) + 11.0,
+ (_random.NextDouble() * 10) + 50.0),
new SimpleMarkerSymbol(new Outline(new MapColor("green")), new MapColor("red"), 10),
new PopupTemplate("Test", "Test Content
{testString}
{testNumber}", ["*"]),
new AttributesDictionary(
@@ -77,17 +90,17 @@ public void SerializeToProtobuf()
ProtoGraphicCollection collection = new([graphic.ToSerializationRecord()]);
using MemoryStream ms = new();
Serializer.Serialize(ms, collection);
- byte[] data = ms.ToArray();
+ var data = ms.ToArray();
sw.Stop();
Console.WriteLine($"SerializeGraphicToJson: {sw.ElapsedMilliseconds}ms");
Console.WriteLine($"Size: {data.Length} bytes");
}
-
+
[TestMethod]
public void RoundTripSerializeToProtobuf()
{
- var graphic = new Graphic(new Point(_random.NextDouble() * 10 + 11.0,
- _random.NextDouble() * 10 + 50.0),
+ var graphic = new Graphic(new Point((_random.NextDouble() * 10) + 11.0,
+ (_random.NextDouble() * 10) + 50.0),
new SimpleMarkerSymbol(new Outline(new MapColor("green")), new MapColor("red"), 10),
new PopupTemplate("Test", "Test Content
{testString}
{testNumber}", ["*"]),
new AttributesDictionary(
@@ -95,14 +108,17 @@ public void RoundTripSerializeToProtobuf()
ProtoGraphicCollection collection = new([graphic.ToSerializationRecord()]);
using MemoryStream ms = new();
Serializer.Serialize(ms, collection);
- byte[] data = ms.ToArray();
- ProtoGraphicCollection deserializedCollection =
+ var data = ms.ToArray();
+
+ var deserializedCollection =
Serializer.Deserialize((ReadOnlyMemory)data);
- Graphic deserialized = deserializedCollection.Graphics[0].FromSerializationRecord();
+ var deserialized = deserializedCollection.Graphics[0].FromSerializationRecord();
Assert.AreEqual(((Point)graphic.Geometry!).Latitude, ((Point)deserialized.Geometry!).Latitude);
Assert.AreEqual(((Point)graphic.Geometry!).Longitude, ((Point)deserialized.Geometry!).Longitude);
Assert.AreEqual(((SimpleMarkerSymbol)graphic.Symbol!).Color, ((SimpleMarkerSymbol)deserialized.Symbol!).Color);
- Assert.AreEqual(((SimpleMarkerSymbol)graphic.Symbol!).Outline!.Color, ((SimpleMarkerSymbol)deserialized.Symbol!).Outline!.Color);
+
+ Assert.AreEqual(((SimpleMarkerSymbol)graphic.Symbol!).Outline!.Color,
+ ((SimpleMarkerSymbol)deserialized.Symbol!).Outline!.Color);
Assert.AreEqual(((SimpleMarkerSymbol)graphic.Symbol!).Size, ((SimpleMarkerSymbol)deserialized.Symbol!).Size);
Assert.AreEqual(graphic.PopupTemplate!.Title, deserialized.PopupTemplate!.Title);
Assert.AreEqual(graphic.Attributes.Count, deserialized.Attributes.Count);
@@ -113,60 +129,61 @@ public void RoundTripSerializeToProtobuf()
[TestMethod]
public void DeserializePortal()
{
- string portalJson = File.ReadAllText("Portal.json");
- Portal portal = JsonSerializer.Deserialize(portalJson)!;
+ var portalJson = File.ReadAllText("Portal.json");
+ var portal = JsonSerializer.Deserialize(portalJson)!;
Assert.IsNotNull(portal);
}
[TestMethod]
public void DeserializePortalWithInvalidRotatorPanels()
{
- string invalidJson =
- """
- {
- "rotatorPanels": {
- "id": "this is one object not an array"
- }
- }
- """;
- Portal portal = JsonSerializer.Deserialize(invalidJson)!;
+ var invalidJson =
+ """
+ {
+ "rotatorPanels": {
+ "id": "this is one object not an array"
+ }
+ }
+ """;
+ var portal = JsonSerializer.Deserialize(invalidJson)!;
Assert.IsNotNull(portal);
Assert.IsNull(portal.RotatorPanels);
}
-
+
[TestMethod]
public void DeserializePortalWithEmptyRotatorPanels()
{
- string invalidJson =
- """
- {
- "rotatorPanels": []
- }
- """;
- Portal portal = JsonSerializer.Deserialize(invalidJson)!;
- Assert.IsNotNull(portal);
- Assert.IsNull(portal.RotatorPanels);
+ var invalidJson =
+ """
+ {
+ "rotatorPanels": []
+ }
+ """;
+ var portal = JsonSerializer.Deserialize(invalidJson)!;
+ Assert.IsNotNull(portal);
+ Assert.IsNull(portal.RotatorPanels);
}
-
+
[TestMethod]
public void DeserializePortalWithEmptyStringAsRotatorPanels()
{
- string invalidJson =
- """
- {
- "rotatorPanels": ""
- }
- """;
- Portal portal = JsonSerializer.Deserialize(invalidJson)!;
- Assert.IsNotNull(portal);
- Assert.IsNull(portal.RotatorPanels);
+ var invalidJson =
+ """
+ {
+ "rotatorPanels": ""
+ }
+ """;
+ var portal = JsonSerializer.Deserialize(invalidJson)!;
+ Assert.IsNotNull(portal);
+ Assert.IsNull(portal.RotatorPanels);
}
[TestMethod]
public void DeserializeWFSCapabilities()
{
- string json = File.ReadAllText("WFSCapabilities.json");
- WFSCapabilities capabilities = JsonSerializer.Deserialize(json,
+ var json = File.ReadAllText("WFSCapabilities.json");
+
+ var capabilities = JsonSerializer.Deserialize(json,
GeoBlazorSerialization.JsonSerializerOptions)!;
Assert.IsNotNull(capabilities.FeatureTypes);
Assert.IsNotEmpty(capabilities.FeatureTypes);
diff --git a/test/dymaptic.GeoBlazor.Core.Test.Unit/dymaptic.GeoBlazor.Core.Test.Unit.csproj b/test/dymaptic.GeoBlazor.Core.Test.Unit/dymaptic.GeoBlazor.Core.Test.Unit.csproj
index 9ed68c8e7..0fa305358 100644
--- a/test/dymaptic.GeoBlazor.Core.Test.Unit/dymaptic.GeoBlazor.Core.Test.Unit.csproj
+++ b/test/dymaptic.GeoBlazor.Core.Test.Unit/dymaptic.GeoBlazor.Core.Test.Unit.csproj
@@ -1,4 +1,4 @@
-
+
net10.0
@@ -17,8 +17,6 @@
-
-
all
runtime; build; native; contentfiles; analyzers; buildtransitive