Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
87 changes: 87 additions & 0 deletions FluentAAS/FluentAAS.Builder.Tests/AasBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,93 @@ public void AddSubmodelFragment_WithoutBaseSubmodel_ShouldThrowInvalidOperationE
ex.Message.ShouldContain("No base submodel");
}

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

var shell = new AssetAdministrationShell(
id: _fixture.Create<string>()!,
assetInformation: new AssetInformation(AssetKind.Instance))
{
IdShort = "shell-with-known-ref",
Submodels =
[
new Reference(
ReferenceTypes.ModelReference,
[new Key(KeyTypes.Submodel, submodelId)])
]
};

builder.AddShellInternal(shell);

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

// Assert
var builtShell = environment.AssetAdministrationShells!.OfType<AssetAdministrationShell>().Single();
builtShell.Submodels.ShouldNotBeNull();
builtShell.Submodels.Count.ShouldBe(1);
builtShell.Submodels.Single().Keys.Single().Value.ShouldBe(submodelId);
}

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

var shell = new AssetAdministrationShell(
id: _fixture.Create<string>()!,
assetInformation: new AssetInformation(AssetKind.Instance))
{
IdShort = "shell-with-unknown-ref",
Submodels =
[
new Reference(
ReferenceTypes.ModelReference,
[new Key(KeyTypes.Submodel, unknownSubmodelId)])
]
};

builder.AddShellInternal(shell);

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

// Assert
var ex = Should.Throw<InvalidOperationException>(act);
ex.Message.ShouldContain($"references unknown submodel id '{unknownSubmodelId}'");
}

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

builder.AddSubmodel(new Submodel(id: existingSubmodelId, idShort: "existing"));
builder.AddSubmodelFragment(existingSubmodelId, fragment => fragment.AddProperty("temperature", "21.5"));
builder.AddSubmodelFragment(missingSubmodelId, fragment => fragment.AddProperty("pressure", "1.0"));

// Act
Should.Throw<InvalidOperationException>(() => builder.Build());

builder.AddSubmodel(new Submodel(id: missingSubmodelId, idShort: "missing-now-added"));
var env = builder.Build();

// Assert
var existingSubmodel = env.Submodels!.OfType<Submodel>().Single(s => s.Id == existingSubmodelId);
existingSubmodel.SubmodelElements.ShouldNotBeNull();
existingSubmodel.SubmodelElements.Count(e => e.IdShort == "temperature").ShouldBe(1);
}

[Fact]
public void Build_WhenCalledMultipleTimes_ShouldReturnNewEnvironmentInstancesWithSameContent()
{
Expand Down
104 changes: 74 additions & 30 deletions FluentAAS/FluentAAS.Builder/AasBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,7 @@ internal AasBuilder()
/// <returns>
/// A <see cref="ShellBuilder"/> instance for further configuration of the shell.
/// </returns>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="id"/> or <paramref name="idShort"/> is null, empty, or whitespace.
/// </exception>
/// <remarks>Throws <see cref="ArgumentException"/> when <paramref name="id"/> or <paramref name="idShort"/> is null, empty, or whitespace.</remarks>
public IShellBuilder AddShell(string id, string idShort, AssetKind kind = AssetKind.Instance)
{
if (string.IsNullOrWhiteSpace(id))
Expand Down Expand Up @@ -75,10 +73,7 @@ public IShellBuilder AddShell(string id, string idShort, AssetKind kind = AssetK
/// </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>
/// <remarks>Throws <see cref="ArgumentNullException"/> when <paramref name="submodel"/> is null and <see cref="ArgumentException"/> when required submodel fields are invalid.</remarks>
public IAasBuilder AddSubmodel(Submodel submodel)
{
ValidateSubmodelForPublicAdd(submodel);
Expand All @@ -91,7 +86,7 @@ public IAasBuilder AddSubmodel(Submodel submodel)
/// </summary>
/// <param name="submodel">The submodel instance to add.</param>
/// <returns>The current <see cref="AasBuilder"/> instance for fluent chaining.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="submodel"/> is null.</exception>
/// <remarks>Throws <see cref="ArgumentNullException"/> when <paramref name="submodel"/> is null.</remarks>
public IAasBuilder AddExistingSubmodel(Submodel submodel)
{
AddSubmodelInternal(submodel);
Expand All @@ -105,8 +100,7 @@ public IAasBuilder AddExistingSubmodel(Submodel submodel)
/// <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>
/// <remarks>Throws <see cref="ArgumentException"/> when <paramref name="submodelId"/> is invalid and <see cref="ArgumentNullException"/> when <paramref name="configure"/> is null.</remarks>
public IAasBuilder AddSubmodelFragment(string submodelId, Action<SubmodelFragmentBuilder> configure)
{
if (string.IsNullOrWhiteSpace(submodelId))
Expand All @@ -128,13 +122,13 @@ public IAasBuilder AddSubmodelFragment(string submodelId, Action<SubmodelFragmen
/// <summary>
/// Builds and returns the configured <see cref="Environment"/> instance.
/// </summary>
/// <returns>
/// A new <see cref="Environment"/> containing the configured asset administration
/// shells and submodels.
/// </returns>
/// <returns>A new <see cref="Environment"/> containing the configured asset administration shells and submodels.</returns>
public IEnvironment Build()
{
var duplicateSubmodelIds = _submodels.OfType<Submodel>()
var workingSubmodels = CloneSubmodels(_submodels);
var workingShells = CloneShells(_shells);

var duplicateSubmodelIds = workingSubmodels.OfType<Submodel>()
.GroupBy(s => s.Id, StringComparer.Ordinal)
.Where(g => !string.IsNullOrWhiteSpace(g.Key) && g.Count() > 1)
.Select(g => g.Key)
Expand All @@ -146,25 +140,28 @@ public IEnvironment Build()
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 submodelsById = workingSubmodels.OfType<Submodel>()
.ToDictionary(sm => sm.Id, StringComparer.Ordinal);

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

applyFragment(submodel);
}

_submodelFragments.Clear();
foreach (var (submodelId, applyFragment) in _submodelFragments)
{
applyFragment(submodelsById[submodelId]);
}

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

foreach (var shell in _shells.OfType<AssetAdministrationShell>())
foreach (var shell in workingShells.OfType<AssetAdministrationShell>())
{
foreach (var reference in shell.Submodels ?? [])
{
Expand All @@ -181,11 +178,17 @@ public IEnvironment Build()
}
}

_submodels.Clear();
_submodels.AddRange(workingSubmodels);
_shells.Clear();
_shells.AddRange(workingShells);
_submodelFragments.Clear();

// Return a fresh Environment each time, with copies of the internal lists
return new Environment
{
AssetAdministrationShells = _shells.ToList(),
Submodels = _submodels.ToList()
AssetAdministrationShells = CloneShells(_shells),
Submodels = CloneSubmodels(_submodels)
};
}

Expand All @@ -194,7 +197,7 @@ public IEnvironment Build()
/// Intended for internal use by <see cref="ShellBuilder"/>.
/// </summary>
/// <param name="shell">The shell instance to add.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="shell"/> is null.</exception>
/// <remarks>Throws <see cref="ArgumentNullException"/> when <paramref name="shell"/> is null.</remarks>
public void AddShellInternal(AssetAdministrationShell shell)
{
ArgumentNullException.ThrowIfNull(shell);
Expand All @@ -210,7 +213,7 @@ public void AddShellInternal(AssetAdministrationShell shell)
/// Intended for internal use by shell or submodel builders.
/// </summary>
/// <param name="submodel">The submodel instance to add.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="submodel"/> is null.</exception>
/// <remarks>Throws <see cref="ArgumentNullException"/> when <paramref name="submodel"/> is null.</remarks>
public void AddSubmodelInternal(Submodel submodel)
{
ArgumentNullException.ThrowIfNull(submodel);
Expand Down Expand Up @@ -241,4 +244,45 @@ private static void ValidateSubmodelForPublicAdd(Submodel submodel)
throw new ArgumentException("Submodel idShort must not be empty.", nameof(submodel));
}
}

private static List<ISubmodel> CloneSubmodels(IEnumerable<ISubmodel> submodels)
{
return submodels.Select(submodel => submodel is Submodel concreteSubmodel
? (ISubmodel)CloneSubmodel(concreteSubmodel)
: submodel)
.ToList();
}

private static Submodel CloneSubmodel(Submodel source)
{
return new Submodel(id: source.Id)
{
IdShort = source.IdShort,
SubmodelElements = source.SubmodelElements?.ToList()
};

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 Preserve full submodel state when cloning

CloneSubmodel recreates a Submodel with only Id, IdShort, and SubmodelElements, so any other configured fields (for example SemanticId set via SubmodelBuilder.WithSemanticId, descriptions, qualifiers, or kind) are silently dropped during Build(). Because Build() then writes these clones back into _submodels, this is destructive: metadata is lost after the first successful build and cannot be recovered in later builds.

Useful? React with 👍 / 👎.

}

private static List<IAssetAdministrationShell> CloneShells(IEnumerable<IAssetAdministrationShell> shells)
{
return shells.Select(shell => shell is AssetAdministrationShell concreteShell
? (IAssetAdministrationShell)CloneShell(concreteShell)
: shell)
.ToList();
}

private static AssetAdministrationShell CloneShell(AssetAdministrationShell source)
{
return new AssetAdministrationShell(id: source.Id, assetInformation: source.AssetInformation)
{
IdShort = source.IdShort,
Submodels = source.Submodels?.Select(CloneReference).ToList()
};

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 Copy all shell properties in CloneShell

CloneShell only copies Id, AssetInformation, IdShort, and Submodels, which strips other valid AssetAdministrationShell data (e.g. DerivedFrom, administration/description fields, extensions, embedded data specs) on every Build(). Since these reduced clones are committed back into _shells, a successful build permanently erases shell metadata and subsequent environments become incomplete.

Useful? React with 👍 / 👎.

}

private static Reference CloneReference(Reference source)
{
return new Reference(
source.Type,
source.Keys.Select(key => new Key(key.Type, key.Value)).ToList());
}
}
23 changes: 7 additions & 16 deletions FluentAAS/FluentAAS.Builder/IAasBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,7 @@ public interface IAasBuilder
/// <returns>
/// A <see cref="ShellBuilder"/> instance for further configuration of the shell.
/// </returns>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="id"/> or <paramref name="idShort"/> is null, empty, or whitespace.
/// </exception>
/// <remarks>Throws <see cref="ArgumentException"/> when <paramref name="id"/> or <paramref name="idShort"/> is null, empty, or whitespace.</remarks>
IShellBuilder AddShell(string id, string idShort, AssetKind kind = AssetKind.Instance);

/// <summary>
Expand All @@ -28,18 +26,15 @@ public interface IAasBuilder
/// </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>
/// <remarks>Throws <see cref="ArgumentNullException"/> when <paramref name="submodel"/> is null and <see cref="ArgumentException"/> when required submodel fields are invalid.</remarks>
IAasBuilder AddSubmodel(Submodel submodel);

/// <summary>
/// Adds an existing <see cref="Submodel"/> to the environment.
/// </summary>
/// <param name="submodel">The submodel instance to add.</param>
/// <returns>The current <see cref="AasBuilder"/> instance for fluent chaining.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="submodel"/> is null.</exception>
/// <remarks>Throws <see cref="ArgumentNullException"/> when <paramref name="submodel"/> is null.</remarks>
IAasBuilder AddExistingSubmodel(Submodel submodel);

/// <summary>
Expand All @@ -49,32 +44,28 @@ public interface IAasBuilder
/// <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>
/// <remarks>Throws <see cref="ArgumentException"/> when <paramref name="submodelId"/> is invalid and <see cref="ArgumentNullException"/> when <paramref name="configure"/> is null.</remarks>
IAasBuilder AddSubmodelFragment(string submodelId, Action<SubmodelFragmentBuilder> configure);

/// <summary>
/// Builds and returns the configured <see cref="Environment"/> instance.
/// </summary>
/// <returns>
/// A new <see cref="Environment"/> containing the configured asset administration
/// shells and submodels.
/// </returns>
/// <returns>A new <see cref="Environment"/> containing the configured asset administration shells and submodels.</returns>
IEnvironment Build();

/// <summary>
/// Adds a fully constructed <see cref="AssetAdministrationShell"/> to the environment.
/// Intended for internal use by <see cref="ShellBuilder"/>.
/// </summary>
/// <param name="shell">The shell instance to add.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="shell"/> is null.</exception>
/// <remarks>Throws <see cref="ArgumentNullException"/> when <paramref name="shell"/> is null.</remarks>
void AddShellInternal(AssetAdministrationShell shell);

/// <summary>
/// Adds a fully constructed <see cref="Submodel"/> to the environment.
/// Intended for internal use by shell or submodel builders.
/// </summary>
/// <param name="submodel">The submodel instance to add.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="submodel"/> is null.</exception>
/// <remarks>Throws <see cref="ArgumentNullException"/> when <paramref name="submodel"/> is null.</remarks>
void AddSubmodelInternal(Submodel submodel);
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,7 @@ internal SubmodelFragmentBuilder(Submodel target)
/// <param name="value">The property value.</param>
/// <param name="valueType">The property value type. Defaults to <see cref="DataTypeDefXsd.String"/>.</param>
/// <returns>The current <see cref="SubmodelFragmentBuilder"/>.</returns>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="idShort"/> or <paramref name="value"/> is null, empty, or whitespace.
/// </exception>
/// <remarks>Throws <see cref="ArgumentException"/> when <paramref name="idShort"/> or <paramref name="value"/> is null, empty, or whitespace.</remarks>
public SubmodelFragmentBuilder AddProperty(string idShort, string value, DataTypeDefXsd valueType = DataTypeDefXsd.String)
{
if (string.IsNullOrWhiteSpace(idShort))
Expand Down Expand Up @@ -52,8 +50,7 @@ public SubmodelFragmentBuilder AddProperty(string idShort, string value, DataTyp
/// <param name="idShort">The short identifier of the multi-language property.</param>
/// <param name="configure">Configuration callback for language strings.</param>
/// <returns>The current <see cref="SubmodelFragmentBuilder"/>.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="idShort"/> is null, empty, or whitespace.</exception>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="configure"/> is null.</exception>
/// <remarks>Throws <see cref="ArgumentException"/> when <paramref name="idShort"/> is invalid and <see cref="ArgumentNullException"/> when <paramref name="configure"/> is null.</remarks>
public SubmodelFragmentBuilder AddMultiLanguageProperty(string idShort, Action<LangStringSetBuilder> configure)
{
if (string.IsNullOrWhiteSpace(idShort))
Expand All @@ -75,7 +72,7 @@ public SubmodelFragmentBuilder AddMultiLanguageProperty(string idShort, Action<L
/// </summary>
/// <param name="element">The submodel element to add.</param>
/// <returns>The current <see cref="SubmodelFragmentBuilder"/>.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="element"/> is null.</exception>
/// <remarks>Throws <see cref="ArgumentNullException"/> when <paramref name="element"/> is null.</remarks>
public SubmodelFragmentBuilder AddElement(ISubmodelElement element)
{
ArgumentNullException.ThrowIfNull(element);
Expand Down
Loading