Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47d148daf9 | |||
| 755ae1aa3f |
@@ -81,7 +81,7 @@ else
|
||||
<tr>
|
||||
<td><span class="mono small">@c.Subject</span></td>
|
||||
<td><span class="mono small">@c.Issuer</span></td>
|
||||
<td><span class="mono small">@c.Thumbprint[..16]…</span></td>
|
||||
<td><span class="mono small">@DisplayText.Abbreviate(c.Thumbprint, 16)</span></td>
|
||||
<td>@c.NotBefore.ToString("u")</td>
|
||||
<td>@c.NotAfter.ToString("u")</td>
|
||||
@if (store.Kind is StoreKind.Trusted or StoreKind.Rejected)
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ else
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="kv"><span class="k">Revision</span><span class="v mono">@_lastDeployment.RevisionHash[..16]…</span></div>
|
||||
<div class="kv"><span class="k">Revision</span><span class="v mono">@DisplayText.Abbreviate(_lastDeployment.RevisionHash, 16)</span></div>
|
||||
<div class="kv"><span class="k">Status</span><span class="v">@_lastDeployment.Status</span></div>
|
||||
<div class="kv"><span class="k">Created</span><span class="v">@_lastDeployment.CreatedAtUtc.ToString("u")</span></div>
|
||||
@if (_lastDeployment.SealedAtUtc is not null)
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
{
|
||||
<tr>
|
||||
<td><code>@Short(d.DeploymentId)</code></td>
|
||||
<td><code>@d.RevisionHash[..12]…</code></td>
|
||||
<td><code>@DisplayText.Abbreviate(d.RevisionHash, 12)</code></td>
|
||||
<td>@d.Status</td>
|
||||
<td>@d.CreatedBy</td>
|
||||
<td>@d.CreatedAtUtc.ToString("u")</td>
|
||||
@@ -113,7 +113,7 @@
|
||||
_lastSuccess = result.Outcome == StartDeploymentOutcome.Accepted;
|
||||
_lastMessage = result.Outcome switch
|
||||
{
|
||||
StartDeploymentOutcome.Accepted => $"Deployment {Short(result.DeploymentId!.Value.Value)} dispatched (rev {result.RevisionHash!.Value.Value[..12]}…).",
|
||||
StartDeploymentOutcome.Accepted => $"Deployment {Short(result.DeploymentId!.Value.Value)} dispatched (rev {DisplayText.Abbreviate(result.RevisionHash!.Value.Value, 12)}).",
|
||||
StartDeploymentOutcome.AnotherDeploymentInFlight => result.Message ?? "Another deployment is already in flight.",
|
||||
StartDeploymentOutcome.NoChanges => "No changes detected since the last sealed deployment.",
|
||||
_ => result.Message ?? "Deployment rejected.",
|
||||
|
||||
@@ -38,7 +38,7 @@ else
|
||||
<span class="mono">@s.ScriptId</span>
|
||||
· <span>@s.Name</span>
|
||||
· <span class="chip chip-idle ms-1">@s.Language</span>
|
||||
<span class="text-muted small ms-2 mono">hash=@s.SourceHash[..12]…</span>
|
||||
<span class="text-muted small ms-2 mono">hash=@DisplayText.Abbreviate(s.SourceHash, 12)</span>
|
||||
</summary>
|
||||
<div style="padding:0 1rem 1rem">
|
||||
<div class="d-flex mb-2">
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Rendering helpers for operator-facing text that comes out of the database.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why this exists (#504).</b> Several pages abbreviated a hash / thumbprint with the range
|
||||
/// operator — <c>@s.SourceHash[..12]…</c> — which throws
|
||||
/// <see cref="ArgumentOutOfRangeException"/> the moment the value is shorter than the slice.
|
||||
/// In Blazor that escapes <c>BuildRenderTree</c> unhandled and takes the <b>whole page</b> to an
|
||||
/// HTTP 500, not just the offending row. Nothing enforces a minimum length at the schema, the
|
||||
/// entity, or the render layer, so any hand-written / seeded / migrated row is a live page-kill.
|
||||
/// The docker-dev seed proved it: its placeholder <c>SourceHash</c> values (<c>h1</c>,
|
||||
/// <c>h-abs-hr200</c>) made <c>/scripts</c> a hard 500 on every stock bring-up.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Use <see cref="Abbreviate"/> for <b>every</b> such prefix. It never throws, and it appends the
|
||||
/// ellipsis only when it actually truncated — a 2-character hash renders as <c>h1</c>, not
|
||||
/// <c>h1…</c>, so the display never implies more text than exists.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class DisplayText
|
||||
{
|
||||
/// <summary>Placeholder rendered for a null / blank value. Matches the em-dash convention the
|
||||
/// Admin UI already uses for "nothing to show" cells.</summary>
|
||||
public const string Empty = "—";
|
||||
|
||||
/// <summary>
|
||||
/// The first <paramref name="maxLength"/> characters of <paramref name="value"/>, followed by an
|
||||
/// ellipsis when (and only when) characters were dropped.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to abbreviate. May be null, blank, or shorter than
|
||||
/// <paramref name="maxLength"/> — none of which throw.</param>
|
||||
/// <param name="maxLength">Maximum number of characters to keep. Values below 1 are treated as 1,
|
||||
/// so a caller cannot turn a display bug into a crash.</param>
|
||||
/// <returns><see cref="Empty"/> for a null/blank value; the value verbatim when it already fits;
|
||||
/// otherwise the truncated prefix plus an ellipsis.</returns>
|
||||
public static string Abbreviate(string? value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return Empty;
|
||||
if (maxLength < 1) maxLength = 1;
|
||||
return value.Length <= maxLength ? value : string.Concat(value.AsSpan(0, maxLength), "…");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// #504 — <see cref="DisplayText.Abbreviate"/>, the null/short-safe replacement for the
|
||||
/// <c>@value[..N]</c> range-operator slices the Admin UI used to abbreviate hashes and thumbprints.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The bug being pinned: a range operator over an unvalidated DB string throws
|
||||
/// <see cref="System.ArgumentOutOfRangeException"/> out of <c>BuildRenderTree</c>, which is unhandled
|
||||
/// in Blazor and takes the WHOLE page to an HTTP 500 — not just the offending row. Nothing enforces a
|
||||
/// minimum length at the schema, entity, or render layer, so the short-input cases below are the ones
|
||||
/// that matter; the happy path is almost incidental.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DisplayTextTests
|
||||
{
|
||||
/// <summary>A value longer than the budget is truncated and marked with an ellipsis — the normal
|
||||
/// case for a 64-char SHA-256.</summary>
|
||||
[Fact]
|
||||
public void A_long_value_is_truncated_and_marked()
|
||||
{
|
||||
DisplayText.Abbreviate(new string('a', 64), 12).ShouldBe(new string('a', 12) + "…");
|
||||
}
|
||||
|
||||
/// <summary>The regression: a value SHORTER than the budget must render, not throw. This is exactly
|
||||
/// what a hand-inserted / API-authored <c>SourceHash</c> like <c>h1</c> hits.</summary>
|
||||
[Theory]
|
||||
[InlineData("h1", 12)]
|
||||
[InlineData("h-abs-hr200", 12)] // 11 chars — one short of the old slice, the original crash
|
||||
[InlineData("abc", 16)]
|
||||
[InlineData("x", 64)]
|
||||
public void A_value_shorter_than_the_budget_renders_verbatim(string value, int maxLength)
|
||||
{
|
||||
DisplayText.Abbreviate(value, maxLength).ShouldBe(value);
|
||||
}
|
||||
|
||||
/// <summary>No ellipsis when nothing was dropped — the display must never imply text that is not
|
||||
/// there. (The old markup appended "…" unconditionally, outside the slice.)</summary>
|
||||
[Fact]
|
||||
public void A_short_value_gets_no_ellipsis()
|
||||
{
|
||||
DisplayText.Abbreviate("h1", 12).ShouldNotContain("…");
|
||||
}
|
||||
|
||||
/// <summary>A value exactly at the budget is the boundary — it fits, so it is neither truncated nor
|
||||
/// marked.</summary>
|
||||
[Fact]
|
||||
public void A_value_exactly_at_the_budget_is_untouched()
|
||||
{
|
||||
DisplayText.Abbreviate("123456789012", 12).ShouldBe("123456789012");
|
||||
}
|
||||
|
||||
/// <summary>Null / blank render as the Admin UI's em-dash placeholder rather than throwing or
|
||||
/// producing a stray ellipsis.</summary>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void A_null_or_blank_value_renders_the_placeholder(string? value)
|
||||
{
|
||||
DisplayText.Abbreviate(value, 12).ShouldBe(DisplayText.Empty);
|
||||
}
|
||||
|
||||
/// <summary>A nonsensical budget is clamped rather than allowed to throw — a display bug must not
|
||||
/// become a page crash, which is the whole point of this helper.</summary>
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(int.MinValue)]
|
||||
public void A_non_positive_budget_is_clamped_not_thrown(int maxLength)
|
||||
{
|
||||
DisplayText.Abbreviate("abcdef", maxLength).ShouldBe("a…");
|
||||
}
|
||||
|
||||
/// <summary>Whatever the inputs, it never throws — the property that keeps a bad row from killing a
|
||||
/// page. Includes the shapes that broke the old code.</summary>
|
||||
[Fact]
|
||||
public void It_never_throws_for_any_combination()
|
||||
{
|
||||
string?[] values = [null, "", " ", "h1", "h-abs-hr200", new string('f', 64), "…"];
|
||||
int[] budgets = [int.MinValue, -1, 0, 1, 12, 16, 64, int.MaxValue];
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
foreach (var budget in budgets)
|
||||
{
|
||||
Should.NotThrow(() => DisplayText.Abbreviate(value, budget));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user