feat(adminui): inline script-source editor in the virtual-tag modal
This commit is contained in:
@@ -47,7 +47,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label" for="vtag-script">Script</label>
|
<label class="form-label" for="vtag-script">Script</label>
|
||||||
<InputSelect id="vtag-script" @bind-Value="_form.ScriptId" class="form-select form-select-sm">
|
<InputSelect id="vtag-script" @bind-Value="_form.ScriptId"
|
||||||
|
@bind-Value:after="OnScriptChangedAsync" class="form-select form-select-sm">
|
||||||
<option value="">— pick script —</option>
|
<option value="">— pick script —</option>
|
||||||
@foreach (var (id, display) in Scripts)
|
@foreach (var (id, display) in Scripts)
|
||||||
{
|
{
|
||||||
@@ -57,6 +58,48 @@
|
|||||||
<ValidationMessage For="@(() => _form.ScriptId)" />
|
<ValidationMessage For="@(() => _form.ScriptId)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@* Inline script-source editor. Shown only when a script is bound. This panel saves
|
||||||
|
the SHARED Script row on its OWN concurrency-guarded button — it is deliberately
|
||||||
|
separate from the virtual-tag Create/Save below and never touches _form or closes
|
||||||
|
the modal. *@
|
||||||
|
@if (!string.IsNullOrEmpty(_form.ScriptId) && _scriptLoaded)
|
||||||
|
{
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||||
|
<span class="fw-semibold">Script source</span>
|
||||||
|
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none"
|
||||||
|
@onclick="ToggleScriptPanel">
|
||||||
|
@(_scriptExpanded ? "Hide" : "Edit source")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (_scriptExpanded)
|
||||||
|
{
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert alert-warning py-2 small mb-2">
|
||||||
|
Editing shared script "<span class="mono">@_scriptName</span>" — used by
|
||||||
|
@_scriptUsageCount virtual tag(s). Changes affect all of them.
|
||||||
|
</div>
|
||||||
|
<MonacoEditor @bind-Value="_scriptSource" Height="300px" />
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_scriptError))
|
||||||
|
{
|
||||||
|
<div class="text-danger small mt-2">@_scriptError</div>
|
||||||
|
}
|
||||||
|
else if (_scriptSaved)
|
||||||
|
{
|
||||||
|
<div class="text-success small mt-2">Script saved.</div>
|
||||||
|
}
|
||||||
|
<div class="mt-2">
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||||
|
@onclick="SaveScriptAsync" disabled="@_scriptSaving">
|
||||||
|
@if (_scriptSaving) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
Save script
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-4 mb-3">
|
<div class="col-md-4 mb-3">
|
||||||
<label class="form-label">Change-triggered</label>
|
<label class="form-label">Change-triggered</label>
|
||||||
@@ -92,8 +135,8 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-secondary" @onclick="CancelAsync" disabled="@_busy">Cancel</button>
|
<button type="button" class="btn btn-outline-secondary" @onclick="CancelAsync" disabled="@(_busy || _scriptSaving)">Cancel</button>
|
||||||
<button type="submit" class="btn btn-primary" disabled="@_busy">
|
<button type="submit" class="btn btn-primary" disabled="@(_busy || _scriptSaving)">
|
||||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
@(IsNew ? "Create" : "Save changes")
|
@(IsNew ? "Create" : "Save changes")
|
||||||
</button>
|
</button>
|
||||||
@@ -135,8 +178,37 @@
|
|||||||
private bool _busy;
|
private bool _busy;
|
||||||
private string? _error;
|
private string? _error;
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
// Tracks which open this modal last loaded for, so unrelated Blazor Server re-renders don't
|
||||||
|
// rebuild _form / reload the script source and clobber in-progress edits. Null while closed.
|
||||||
|
private string? _loadedKey;
|
||||||
|
|
||||||
|
// Inline script-source editor state. Independent of _form and the virtual-tag save.
|
||||||
|
private string _scriptSource = "";
|
||||||
|
private bool _scriptLoaded;
|
||||||
|
private byte[] _scriptRowVersion = Array.Empty<byte>();
|
||||||
|
private string? _scriptName;
|
||||||
|
private int _scriptUsageCount;
|
||||||
|
private bool _scriptExpanded;
|
||||||
|
private bool _scriptSaving;
|
||||||
|
private bool _scriptSaved;
|
||||||
|
private string? _scriptError;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
{
|
{
|
||||||
|
if (!Visible)
|
||||||
|
{
|
||||||
|
_loadedKey = null; // closed → next open reloads fresh
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard against unrelated re-renders. In Blazor Server any live-status push re-invokes
|
||||||
|
// OnParametersSetAsync; without this the rebuild + script reload below would silently
|
||||||
|
// discard whatever the operator has typed. Only rebuild when the modal OPENS or the
|
||||||
|
// target entity CHANGES.
|
||||||
|
var key = IsNew ? "<new>" : Existing?.VirtualTagId;
|
||||||
|
if (key == _loadedKey) return; // same open, re-render → preserve in-progress form + script edits
|
||||||
|
_loadedKey = key;
|
||||||
|
|
||||||
// Rebuild the working form whenever the host (re)opens the modal for a fresh target.
|
// Rebuild the working form whenever the host (re)opens the modal for a fresh target.
|
||||||
if (IsNew)
|
if (IsNew)
|
||||||
{
|
{
|
||||||
@@ -157,6 +229,105 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
_error = null;
|
_error = null;
|
||||||
|
_scriptExpanded = false;
|
||||||
|
|
||||||
|
// Load the bound script's source for the inline panel when editing an existing tag that
|
||||||
|
// already has a script (the open-load path; selection changes go through OnScriptChangedAsync).
|
||||||
|
await LoadScriptSourceAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reloads the inline-editor state from the currently selected script. Clears it when no script is
|
||||||
|
/// bound. This drives both the open-load and the post-selection refresh, and never touches _form.
|
||||||
|
/// </summary>
|
||||||
|
private async Task LoadScriptSourceAsync()
|
||||||
|
{
|
||||||
|
_scriptSaved = false;
|
||||||
|
_scriptError = null;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(_form.ScriptId))
|
||||||
|
{
|
||||||
|
_scriptSource = "";
|
||||||
|
_scriptLoaded = false;
|
||||||
|
_scriptRowVersion = Array.Empty<byte>();
|
||||||
|
_scriptName = null;
|
||||||
|
_scriptUsageCount = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var loaded = await Svc.GetScriptSourceAsync(_form.ScriptId);
|
||||||
|
if (loaded is null)
|
||||||
|
{
|
||||||
|
_scriptSource = "";
|
||||||
|
_scriptLoaded = false;
|
||||||
|
_scriptRowVersion = Array.Empty<byte>();
|
||||||
|
_scriptName = null;
|
||||||
|
_scriptUsageCount = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_scriptSource = loaded.Value.SourceCode;
|
||||||
|
_scriptLoaded = true;
|
||||||
|
_scriptRowVersion = loaded.Value.RowVersion;
|
||||||
|
_scriptName = loaded.Value.Name;
|
||||||
|
_scriptUsageCount = await Svc.CountVirtualTagsUsingScriptAsync(_form.ScriptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Refreshes the inline script panel when the operator picks a different script.</summary>
|
||||||
|
private Task OnScriptChangedAsync() => LoadScriptSourceAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Toggles the inline script-source panel. Clears the stale "Script saved." banner when
|
||||||
|
/// (re)expanding so a collapse/expand cycle doesn't re-show a confirmation from an earlier save.
|
||||||
|
/// </summary>
|
||||||
|
private void ToggleScriptPanel()
|
||||||
|
{
|
||||||
|
_scriptExpanded = !_scriptExpanded;
|
||||||
|
if (_scriptExpanded)
|
||||||
|
{
|
||||||
|
_scriptSaved = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves the edited script body via its own RowVersion-guarded service call. This is wholly
|
||||||
|
/// separate from the virtual-tag save: it does not touch _form, does not raise OnSaved, and does
|
||||||
|
/// not close the modal. On success it re-fetches the source so the panel holds a fresh token.
|
||||||
|
/// </summary>
|
||||||
|
private async Task SaveScriptAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_form.ScriptId) || !_scriptLoaded)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_scriptSaving = true;
|
||||||
|
_scriptSaved = false;
|
||||||
|
_scriptError = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await Svc.UpdateScriptSourceAsync(_form.ScriptId, _scriptSource, _scriptRowVersion);
|
||||||
|
if (result.Ok)
|
||||||
|
{
|
||||||
|
// Re-fetch to refresh the concurrency token for any subsequent save.
|
||||||
|
var reloaded = await Svc.GetScriptSourceAsync(_form.ScriptId);
|
||||||
|
if (reloaded is not null)
|
||||||
|
{
|
||||||
|
_scriptSource = reloaded.Value.SourceCode;
|
||||||
|
_scriptRowVersion = reloaded.Value.RowVersion;
|
||||||
|
_scriptName = reloaded.Value.Name;
|
||||||
|
}
|
||||||
|
_scriptSaved = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_scriptError = result.Error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_scriptSaving = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SaveAsync()
|
private async Task SaveAsync()
|
||||||
|
|||||||
@@ -401,4 +401,36 @@ public interface IUnsTreeService
|
|||||||
/// <param name="ct">A token to cancel the operation.</param>
|
/// <param name="ct">A token to cancel the operation.</param>
|
||||||
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
|
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
|
||||||
Task<UnsMutationResult> DeleteVirtualTagAsync(string virtualTagId, byte[] rowVersion, CancellationToken ct = default);
|
Task<UnsMutationResult> DeleteVirtualTagAsync(string virtualTagId, byte[] rowVersion, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads a script's editable source for the inline script-source panel in the virtual-tag modal,
|
||||||
|
/// along with the concurrency token the panel must echo back on save and the script's display name.
|
||||||
|
/// Reads untracked. Returns <c>null</c> when the script no longer exists.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="scriptId">The script whose source to load.</param>
|
||||||
|
/// <param name="ct">A token to cancel the load.</param>
|
||||||
|
/// <returns>The <c>(SourceCode, RowVersion, Name)</c> triple, or <c>null</c> when missing.</returns>
|
||||||
|
Task<(string SourceCode, byte[] RowVersion, string Name)?> GetScriptSourceAsync(string scriptId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Counts how many virtual tags bind the given script, so the inline editor can warn the operator
|
||||||
|
/// that an edit to a shared script affects every virtual tag using it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="scriptId">The script to count usages of.</param>
|
||||||
|
/// <param name="ct">A token to cancel the query.</param>
|
||||||
|
/// <returns>The number of virtual tags whose <c>ScriptId</c> matches.</returns>
|
||||||
|
Task<int> CountVirtualTagsUsingScriptAsync(string scriptId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves an edited script body from the inline panel: updates <c>SourceCode</c> and recomputes the
|
||||||
|
/// SHA-256 <c>SourceHash</c> (lower-case hex, matching the Script-edit page). This save is separate
|
||||||
|
/// from the virtual-tag save and is guarded by its own last-write-wins optimistic concurrency on
|
||||||
|
/// <see cref="Configuration.Entities.Script.RowVersion"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="scriptId">The script to update.</param>
|
||||||
|
/// <param name="sourceCode">The new source body.</param>
|
||||||
|
/// <param name="rowVersion">The concurrency token the panel last read.</param>
|
||||||
|
/// <param name="ct">A token to cancel the operation.</param>
|
||||||
|
/// <returns>Success, a missing-row failure, or a concurrency failure.</returns>
|
||||||
|
Task<(bool Ok, string? Error)> UpdateScriptSourceAsync(string scriptId, string sourceCode, byte[] rowVersion, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
@@ -1014,6 +1016,67 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<(string SourceCode, byte[] RowVersion, string Name)?> GetScriptSourceAsync(
|
||||||
|
string scriptId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||||
|
|
||||||
|
var row = await db.Scripts
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(s => s.ScriptId == scriptId)
|
||||||
|
.Select(s => new { s.SourceCode, s.RowVersion, s.Name })
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
return row is null ? null : (row.SourceCode, row.RowVersion, row.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<int> CountVirtualTagsUsingScriptAsync(string scriptId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||||
|
return await db.VirtualTags.CountAsync(v => v.ScriptId == scriptId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<(bool Ok, string? Error)> UpdateScriptSourceAsync(
|
||||||
|
string scriptId,
|
||||||
|
string sourceCode,
|
||||||
|
byte[] rowVersion,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||||
|
|
||||||
|
var entity = await db.Scripts.FirstOrDefaultAsync(s => s.ScriptId == scriptId, ct);
|
||||||
|
if (entity is null)
|
||||||
|
{
|
||||||
|
return (false, "Script not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
||||||
|
|
||||||
|
entity.SourceCode = sourceCode;
|
||||||
|
entity.SourceHash = HashSource(sourceCode);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return (true, null);
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
return (false, "This script was changed by someone else. Reload and try again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the SHA-256 of a script body as lower-case hex — the same algorithm the Script-edit
|
||||||
|
/// page uses, so a body saved from either surface yields an identical compile-cache key.
|
||||||
|
/// </summary>
|
||||||
|
private static string HashSource(string source) =>
|
||||||
|
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(source)));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates a virtual tag's script binding and trigger configuration (mirrors the DbContext CHECK
|
/// Validates a virtual tag's script binding and trigger configuration (mirrors the DbContext CHECK
|
||||||
/// constraints): a script must be chosen, at least one of change-trigger / timer must be set, and a
|
/// constraints): a script must be chosen, at least one of change-trigger / timer must be set, and a
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the inline script-source service methods on <see cref="UnsTreeService"/> that back the
|
||||||
|
/// virtual-tag modal's script-editing panel: loading a script's source + concurrency token + name,
|
||||||
|
/// counting how many virtual tags share a script, and saving an edited body (recomputing the SHA-256
|
||||||
|
/// <c>SourceHash</c>) under its own optimistic-concurrency guard.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The EF InMemory provider honours neither a stale <c>RowVersion</c> token nor the resulting
|
||||||
|
/// <see cref="DbUpdateConcurrencyException"/> on a same-row update, exactly as the sibling
|
||||||
|
/// <c>UnsTreeServiceVirtualTagTests</c> note — so the live concurrency-conflict catch (the
|
||||||
|
/// "changed by someone else" message) cannot be exercised here and is verified against the real
|
||||||
|
/// SQL Server in Task 12. These tests cover the load, count, success, and not-found contracts, plus
|
||||||
|
/// the realistic case where the script vanished before the save lands (a concurrent delete), which
|
||||||
|
/// the service reports as a failure rather than throwing.
|
||||||
|
/// </remarks>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class ScriptSourceServiceTests
|
||||||
|
{
|
||||||
|
private static (UnsTreeService Service, string DbName) Fresh()
|
||||||
|
{
|
||||||
|
var dbName = $"uns-scriptsrc-{Guid.NewGuid():N}";
|
||||||
|
return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds a single script (<c>SCRIPT-1</c>) plus an area→line→equipment path carrying two virtual
|
||||||
|
/// tags bound to it and a third bound to a different script, so the usage count is unambiguous.
|
||||||
|
/// </summary>
|
||||||
|
private static void SeedScriptAndUsages(string dbName)
|
||||||
|
{
|
||||||
|
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||||
|
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = "MAIN", Name = "a" });
|
||||||
|
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" });
|
||||||
|
db.Equipment.Add(new Equipment
|
||||||
|
{
|
||||||
|
EquipmentId = "EQ-1",
|
||||||
|
EquipmentUuid = Guid.NewGuid(),
|
||||||
|
UnsLineId = "LINE-1",
|
||||||
|
Name = "machine-1",
|
||||||
|
MachineCode = "machine_001",
|
||||||
|
});
|
||||||
|
db.Scripts.Add(new Script
|
||||||
|
{
|
||||||
|
ScriptId = "SCRIPT-1",
|
||||||
|
Name = "compute speed",
|
||||||
|
SourceCode = "return 1;",
|
||||||
|
SourceHash = HashOf("return 1;"),
|
||||||
|
Language = "CSharp",
|
||||||
|
});
|
||||||
|
db.Scripts.Add(new Script
|
||||||
|
{
|
||||||
|
ScriptId = "SCRIPT-2",
|
||||||
|
Name = "other",
|
||||||
|
SourceCode = "return 2;",
|
||||||
|
SourceHash = HashOf("return 2;"),
|
||||||
|
Language = "CSharp",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Two virtual tags use SCRIPT-1; one uses SCRIPT-2.
|
||||||
|
db.VirtualTags.Add(new VirtualTag
|
||||||
|
{
|
||||||
|
VirtualTagId = "VTAG-A", EquipmentId = "EQ-1", Name = "vt_a", DataType = "Double", ScriptId = "SCRIPT-1",
|
||||||
|
});
|
||||||
|
db.VirtualTags.Add(new VirtualTag
|
||||||
|
{
|
||||||
|
VirtualTagId = "VTAG-B", EquipmentId = "EQ-1", Name = "vt_b", DataType = "Double", ScriptId = "SCRIPT-1",
|
||||||
|
});
|
||||||
|
db.VirtualTags.Add(new VirtualTag
|
||||||
|
{
|
||||||
|
VirtualTagId = "VTAG-C", EquipmentId = "EQ-1", Name = "vt_c", DataType = "Double", ScriptId = "SCRIPT-2",
|
||||||
|
});
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string HashOf(string source) =>
|
||||||
|
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(source)));
|
||||||
|
|
||||||
|
// ----- GetScriptSourceAsync -----
|
||||||
|
|
||||||
|
/// <summary>Loading a known script returns its source, concurrency token, and display name.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetScriptSource_returns_source_and_name()
|
||||||
|
{
|
||||||
|
var (service, dbName) = Fresh();
|
||||||
|
SeedScriptAndUsages(dbName);
|
||||||
|
|
||||||
|
var loaded = await service.GetScriptSourceAsync("SCRIPT-1");
|
||||||
|
|
||||||
|
loaded.ShouldNotBeNull();
|
||||||
|
loaded.Value.SourceCode.ShouldBe("return 1;");
|
||||||
|
loaded.Value.Name.ShouldBe("compute speed");
|
||||||
|
loaded.Value.RowVersion.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Loading an unknown script id returns null.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetScriptSource_unknown_returns_null()
|
||||||
|
{
|
||||||
|
var (service, dbName) = Fresh();
|
||||||
|
SeedScriptAndUsages(dbName);
|
||||||
|
|
||||||
|
var loaded = await service.GetScriptSourceAsync("SCRIPT-NOPE");
|
||||||
|
|
||||||
|
loaded.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- CountVirtualTagsUsingScriptAsync -----
|
||||||
|
|
||||||
|
/// <summary>The usage count reflects only the virtual tags bound to the given script.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task CountVirtualTagsUsingScript_returns_count()
|
||||||
|
{
|
||||||
|
var (service, dbName) = Fresh();
|
||||||
|
SeedScriptAndUsages(dbName);
|
||||||
|
|
||||||
|
(await service.CountVirtualTagsUsingScriptAsync("SCRIPT-1")).ShouldBe(2);
|
||||||
|
(await service.CountVirtualTagsUsingScriptAsync("SCRIPT-2")).ShouldBe(1);
|
||||||
|
(await service.CountVirtualTagsUsingScriptAsync("SCRIPT-NOPE")).ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- UpdateScriptSourceAsync -----
|
||||||
|
|
||||||
|
/// <summary>A save with the current token updates the source and recomputes the SHA-256 hash.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateScriptSource_updates_source_and_recomputes_hash()
|
||||||
|
{
|
||||||
|
var (service, dbName) = Fresh();
|
||||||
|
SeedScriptAndUsages(dbName);
|
||||||
|
|
||||||
|
byte[] rv;
|
||||||
|
string originalHash;
|
||||||
|
using (var db = UnsTreeTestDb.CreateNamed(dbName))
|
||||||
|
{
|
||||||
|
var script = db.Scripts.Single(s => s.ScriptId == "SCRIPT-1");
|
||||||
|
rv = script.RowVersion;
|
||||||
|
originalHash = script.SourceHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
const string newSource = "return 42;";
|
||||||
|
var result = await service.UpdateScriptSourceAsync("SCRIPT-1", newSource, rv);
|
||||||
|
|
||||||
|
result.Ok.ShouldBeTrue();
|
||||||
|
result.Error.ShouldBeNull();
|
||||||
|
|
||||||
|
using var verify = UnsTreeTestDb.CreateNamed(dbName);
|
||||||
|
var after = verify.Scripts.Single(s => s.ScriptId == "SCRIPT-1");
|
||||||
|
after.SourceCode.ShouldBe(newSource);
|
||||||
|
after.SourceHash.ShouldBe(HashOf(newSource));
|
||||||
|
after.SourceHash.ShouldNotBe(originalHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Saving a script id that no longer exists returns the not-found error.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateScriptSource_missing_returns_error()
|
||||||
|
{
|
||||||
|
var (service, dbName) = Fresh();
|
||||||
|
SeedScriptAndUsages(dbName);
|
||||||
|
|
||||||
|
var result = await service.UpdateScriptSourceAsync("SCRIPT-NOPE", "x", []);
|
||||||
|
|
||||||
|
result.Ok.ShouldBeFalse();
|
||||||
|
result.Error.ShouldBe("Script not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A save whose target row was changed/removed by someone else after the panel captured its token
|
||||||
|
/// surfaces a non-null error instead of throwing. InMemory cannot raise the real
|
||||||
|
/// <see cref="DbUpdateConcurrencyException"/> for a same-row stale token, so this exercises the
|
||||||
|
/// concurrent-delete leg of the same race (the panel holds a now-stale token; the row is gone),
|
||||||
|
/// which the service reports as a failure. The live "changed by someone else" concurrency message
|
||||||
|
/// is verified against real SQL Server in Task 12.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateScriptSource_stale_token_after_concurrent_delete_returns_error()
|
||||||
|
{
|
||||||
|
var (service, dbName) = Fresh();
|
||||||
|
SeedScriptAndUsages(dbName);
|
||||||
|
|
||||||
|
byte[] staleRv;
|
||||||
|
using (var db = UnsTreeTestDb.CreateNamed(dbName))
|
||||||
|
{
|
||||||
|
staleRv = db.Scripts.Single(s => s.ScriptId == "SCRIPT-1").RowVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another editor removes the row out from under us before our save lands.
|
||||||
|
using (var other = UnsTreeTestDb.CreateNamed(dbName))
|
||||||
|
{
|
||||||
|
var row = other.Scripts.Single(s => s.ScriptId == "SCRIPT-1");
|
||||||
|
other.Scripts.Remove(row);
|
||||||
|
other.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await service.UpdateScriptSourceAsync("SCRIPT-1", "return 99;", staleRv);
|
||||||
|
|
||||||
|
result.Ok.ShouldBeFalse();
|
||||||
|
result.Error.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user