Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions FluentAAS/FluentAAS.Builder.Tests/AasBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,121 @@ public void AddExistingSubmodel_WithValidSubmodel_ShouldAddToEnvironmentAndRetur
environment.Submodels.Single().ShouldBeSameAs(submodel);
}

[Fact]
public void AddSubmodel_WithValidSubmodel_ShouldAddToEnvironmentAndReturnBuilder()
{
// Arrange
var builder = CreateSut();
var submodel = new Submodel(
id: _fixture.Create<string>()!,
idShort: _fixture.Create<string>()!
);

// Act
var returned = builder.AddSubmodel(submodel);

// Assert
returned.ShouldBeSameAs(builder);

var environment = builder.Build();
environment.Submodels.ShouldNotBeNull();
environment.Submodels.ShouldContain(submodel);
}

[Fact]
public void AddSubmodel_WithInvalidIdShort_ShouldThrowArgumentException()
{
// Arrange
var builder = CreateSut();
var submodel = new Submodel(id: _fixture.Create<string>()!)
{
IdShort = " "
};

// Act
var act = () => builder.AddSubmodel(submodel);

// Assert
var ex = Should.Throw<ArgumentException>(act);
ex.ParamName.ShouldBe("submodel");
}

[Fact]
public void Build_WithDuplicateSubmodelIds_ShouldThrowInvalidOperationException()
{
// Arrange
var builder = CreateSut();
var duplicateId = _fixture.Create<string>();

builder.AddSubmodel(new Submodel(id: duplicateId, idShort: "submodel-a"));
builder.AddSubmodel(new Submodel(id: duplicateId, idShort: "submodel-b"));

// Act
var act = () => builder.Build();

// Assert
var ex = Should.Throw<InvalidOperationException>(act);
ex.Message.ShouldContain("Duplicate submodel id(s) detected at build-time");
}

[Fact]
public void AddSubmodelFragment_ShouldApplyContributionAtBuildTime()
{
// Arrange
var builder = CreateSut();
var submodelId = _fixture.Create<string>();
builder.AddSubmodel(new Submodel(id: submodelId, idShort: "composed-submodel"));

// Act
builder.AddSubmodelFragment(submodelId, fragment => fragment.AddProperty("temperature", "21.5"));
var env = builder.Build();

// Assert
var submodel = env.Submodels.ShouldHaveSingleItem().ShouldBeOfType<Submodel>();
submodel.SubmodelElements.ShouldNotBeNull();
submodel.SubmodelElements.ShouldContain(e => e.IdShort == "temperature");
}

[Fact]
public void Build_WhenCalledMultipleTimes_WithFragments_ShouldNotDuplicateFragmentElements()
{
// Arrange
var builder = CreateSut();
var submodelId = _fixture.Create<string>();
builder.AddSubmodel(new Submodel(id: submodelId, idShort: "composed-submodel"));
builder.AddSubmodelFragment(submodelId, fragment => fragment.AddProperty("temperature", "21.5"));

// Act
var env1 = builder.Build();
var env2 = builder.Build();

// Assert
var firstSubmodel = env1.Submodels.ShouldHaveSingleItem().ShouldBeOfType<Submodel>();
var secondSubmodel = env2.Submodels.ShouldHaveSingleItem().ShouldBeOfType<Submodel>();

firstSubmodel.SubmodelElements.ShouldNotBeNull();
secondSubmodel.SubmodelElements.ShouldNotBeNull();

firstSubmodel.SubmodelElements.Count(e => e.IdShort == "temperature").ShouldBe(1);
secondSubmodel.SubmodelElements.Count(e => e.IdShort == "temperature").ShouldBe(1);
}

[Fact]
public void AddSubmodelFragment_WithoutBaseSubmodel_ShouldThrowInvalidOperationException()
{
// Arrange
var builder = CreateSut();
var missingId = _fixture.Create<string>();
builder.AddSubmodelFragment(missingId, fragment => fragment.AddProperty("p", "v"));

// Act
var act = () => builder.Build();

// Assert
var ex = Should.Throw<InvalidOperationException>(act);
ex.Message.ShouldContain("No base submodel");
}

[Fact]
public void Build_WhenCalledMultipleTimes_ShouldReturnNewEnvironmentInstancesWithSameContent()
{
Expand Down Expand Up @@ -251,4 +366,20 @@ public void AddSubmodelInternal_WithValidSubmodel_ShouldAddSubmodelToEnvironment
environment.Submodels.ShouldHaveSingleItem();
environment.Submodels.Single().ShouldBeSameAs(submodel);
}

[Fact]
public void AddSubmodelInternal_WithMissingIdShort_ShouldStillAddSubmodel()
{
// Arrange
var builder = CreateSut();
var submodel = new Submodel(id: _fixture.Create<string>()!);

// Act
builder.AddSubmodelInternal(submodel);
var environment = builder.Build();

// Assert
environment.Submodels.ShouldHaveSingleItem();
environment.Submodels.Single().ShouldBeSameAs(submodel);
}
}
121 changes: 118 additions & 3 deletions FluentAAS/FluentAAS.Builder/AasBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using FluentAAS.Builder.SubModel;

namespace FluentAAS.Builder;

/// <summary>
Expand All @@ -6,8 +8,9 @@ namespace FluentAAS.Builder;
/// </summary>
public sealed class AasBuilder : IAasBuilder
{
private readonly List<IAssetAdministrationShell> _shells = [];
private readonly List<ISubmodel> _submodels = [];
private readonly List<IAssetAdministrationShell> _shells = [];
private readonly List<ISubmodel> _submodels = [];
private readonly List<(string SubmodelId, Action<Submodel> Fn)> _submodelFragments = [];

/// <summary>
/// Initializes a new instance of the <see cref="AasBuilder"/> class.
Expand Down Expand Up @@ -66,6 +69,23 @@ public IShellBuilder AddShell(string id, string idShort, AssetKind kind = AssetK
return new ShellBuilder(this, shell);
}

/// <summary>
/// Adds an existing, fully constructed <see cref="Submodel"/> to the environment.
/// The submodel participates in build-time uniqueness validation.
/// </summary>
/// <param name="submodel">The submodel instance to add.</param>
/// <returns>The current <see cref="IAasBuilder"/> instance for fluent chaining.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="submodel"/> is null.</exception>
/// <exception cref="ArgumentException">
/// Thrown when required submodel fields are invalid.
/// </exception>
public IAasBuilder AddSubmodel(Submodel submodel)
{
ValidateSubmodelForPublicAdd(submodel);
AddSubmodelInternal(submodel);
return this;
}

/// <summary>
/// Adds an existing <see cref="Submodel"/> to the environment.
/// </summary>
Expand All @@ -78,6 +98,33 @@ public IAasBuilder AddExistingSubmodel(Submodel submodel)
return this;
}

/// <summary>
/// Adds a staged fragment to an already registered submodel, identified by submodel id.
/// Fragments are applied in registration order during <see cref="Build"/>.
/// </summary>
/// <param name="submodelId">The identifier of the target submodel.</param>
/// <param name="configure">Callback that contributes submodel elements.</param>
/// <returns>The current <see cref="IAasBuilder"/> instance for fluent chaining.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="submodelId"/> is null, empty, or whitespace.</exception>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="configure"/> is null.</exception>
public IAasBuilder AddSubmodelFragment(string submodelId, Action<SubmodelFragmentBuilder> configure)
{
if (string.IsNullOrWhiteSpace(submodelId))
{
throw new ArgumentException("Submodel id must not be empty.", nameof(submodelId));
}

ArgumentNullException.ThrowIfNull(configure);

_submodelFragments.Add((submodelId, submodel =>
{
var fragment = new SubmodelFragmentBuilder(submodel);
configure(fragment);
}));

return this;
}

/// <summary>
/// Builds and returns the configured <see cref="Environment"/> instance.
/// </summary>
Expand All @@ -87,6 +134,53 @@ public IAasBuilder AddExistingSubmodel(Submodel submodel)
/// </returns>
public IEnvironment Build()
{
var duplicateSubmodelIds = _submodels.OfType<Submodel>()
.GroupBy(s => s.Id, StringComparer.Ordinal)
.Where(g => !string.IsNullOrWhiteSpace(g.Key) && g.Count() > 1)
.Select(g => g.Key)
.ToList();

if (duplicateSubmodelIds.Count > 0)
{
var duplicateIdList = string.Join(", ", duplicateSubmodelIds.Select(id => $"'{id}'"));
throw new InvalidOperationException($"Duplicate submodel id(s) detected at build-time: {duplicateIdList}. Each submodel id must be unique.");
}

foreach (var (submodelId, applyFragment) in _submodelFragments)
{
var submodel = _submodels.OfType<Submodel>().FirstOrDefault(sm => string.Equals(sm.Id, submodelId, StringComparison.Ordinal));
if (submodel is null)
{
throw new InvalidOperationException($"No base submodel with id '{submodelId}' was found for a staged fragment. Add the submodel before adding fragments.");
}

applyFragment(submodel);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid mutating staged submodels during each Build

Build() executes applyFragment(submodel) against the builder’s stored Submodel instances on every invocation, and _submodelFragments is never consumed or reset. This makes repeated Build() calls non-idempotent (fragment elements are appended again each time) and also mutates previously returned environments because they share the same submodel objects. Apply fragments to a fresh copy (or apply each fragment only once) before returning the environment.

Useful? React with 👍 / 👎.

}

_submodelFragments.Clear();

var knownSubmodelIds = _submodels.OfType<Submodel>()
.Select(s => s.Id)
.Where(id => !string.IsNullOrWhiteSpace(id))
.ToHashSet(StringComparer.Ordinal);

foreach (var shell in _shells.OfType<AssetAdministrationShell>())
{
foreach (var reference in shell.Submodels ?? [])
{
var referencedSubmodelId = reference.Keys.FirstOrDefault()?.Value;
if (string.IsNullOrWhiteSpace(referencedSubmodelId))
{
throw new InvalidOperationException($"Shell '{shell.Id}' contains a submodel reference without a valid identifier.");
}

if (!knownSubmodelIds.Contains(referencedSubmodelId))
{
throw new InvalidOperationException($"Shell '{shell.Id}' references unknown submodel id '{referencedSubmodelId}'.");
}
}
}

// Return a fresh Environment each time, with copies of the internal lists
return new Environment
{
Expand Down Expand Up @@ -120,10 +214,31 @@ public void AddShellInternal(AssetAdministrationShell shell)
public void AddSubmodelInternal(Submodel submodel)
{
ArgumentNullException.ThrowIfNull(submodel);
ValidateSubmodelId(submodel);

if (!_submodels.Contains(submodel))
{
_submodels.Add(submodel);
}
}
}

private static void ValidateSubmodelId(Submodel submodel)
{
ArgumentNullException.ThrowIfNull(submodel);

if (string.IsNullOrWhiteSpace(submodel.Id))
{
throw new ArgumentException("Submodel id must not be empty.", nameof(submodel));
}
}

private static void ValidateSubmodelForPublicAdd(Submodel submodel)
{
ValidateSubmodelId(submodel);

if (string.IsNullOrWhiteSpace(submodel.IdShort))
{
throw new ArgumentException("Submodel idShort must not be empty.", nameof(submodel));
}
}
}
27 changes: 26 additions & 1 deletion FluentAAS/FluentAAS.Builder/IAasBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using FluentAAS.Builder.SubModel;

namespace FluentAAS.Builder;

public interface IAasBuilder
Expand All @@ -20,6 +22,18 @@ public interface IAasBuilder
/// </exception>
IShellBuilder AddShell(string id, string idShort, AssetKind kind = AssetKind.Instance);

/// <summary>
/// Adds an existing, fully constructed <see cref="Submodel"/> to the environment.
/// The submodel participates in build-time uniqueness validation.
/// </summary>
/// <param name="submodel">The submodel instance to add.</param>
/// <returns>The current <see cref="IAasBuilder"/> instance for fluent chaining.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="submodel"/> is null.</exception>
/// <exception cref="ArgumentException">
/// Thrown when required submodel fields (for example <see cref="IReferable.IdShort"/>) are invalid.
/// </exception>
IAasBuilder AddSubmodel(Submodel submodel);

/// <summary>
/// Adds an existing <see cref="Submodel"/> to the environment.
/// </summary>
Expand All @@ -28,6 +42,17 @@ public interface IAasBuilder
/// <exception cref="ArgumentNullException">Thrown when <paramref name="submodel"/> is null.</exception>
IAasBuilder AddExistingSubmodel(Submodel submodel);

/// <summary>
/// Adds a staged fragment to an already registered submodel, identified by submodel id.
/// Fragments are applied in registration order during <see cref="Build"/>.
/// </summary>
/// <param name="submodelId">The identifier of the target submodel.</param>
/// <param name="configure">Callback that contributes submodel elements.</param>
/// <returns>The current <see cref="IAasBuilder"/> instance for fluent chaining.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="submodelId"/> is null, empty, or whitespace.</exception>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="configure"/> is null.</exception>
IAasBuilder AddSubmodelFragment(string submodelId, Action<SubmodelFragmentBuilder> configure);

/// <summary>
/// Builds and returns the configured <see cref="Environment"/> instance.
/// </summary>
Expand All @@ -52,4 +77,4 @@ public interface IAasBuilder
/// <param name="submodel">The submodel instance to add.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="submodel"/> is null.</exception>
void AddSubmodelInternal(Submodel submodel);
}
}
Loading