diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/TemplateCrudTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/TemplateCrudTests.cs index 5b16db54..9daf8689 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/TemplateCrudTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/TemplateCrudTests.cs @@ -1,5 +1,7 @@ using Microsoft.Playwright; using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; +using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment; +using Xunit; namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Design; @@ -9,13 +11,15 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Design; /// running dev cluster. /// [Collection("Playwright")] -public class TemplateCrudTests +public class TemplateCrudTests : IClassFixture { private readonly PlaywrightFixture _fixture; + private readonly DeploymentFixture _cluster; - public TemplateCrudTests(PlaywrightFixture fixture) + public TemplateCrudTests(PlaywrightFixture fixture, DeploymentFixture cluster) { _fixture = fixture; + _cluster = cluster; } [SkippableFact] @@ -212,4 +216,122 @@ public class TemplateCrudTests } } } + + [SkippableFact] + public async Task EditAttribute_PersistsChange() + { + Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason); + + // CLI-seed a template with a single Double "Val" attribute, then edit its + // Value through the page-local Edit-Attribute modal and confirm it persists. + var name = CliRunner.UniqueName("tmpl"); + var id = await CliRunner.CreateTemplateAsync(name); + await CliRunner.AddAttributeAsync(id, "Val", "Double"); + + try + { + var page = await _fixture.NewAuthenticatedPageAsync(); + + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/templates/{id}"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + // Open the Val row's actions dropdown, then click the "Edit…" item + // (note the ellipsis char "…" — it is in the markup verbatim). + await page.ClickAsync("button[aria-label=\"More actions for Val\"]"); + await page.ClickAsync("button.dropdown-item:has-text('Edit…')"); + + // The page-local attribute modal is .modal.show.d-block WITHOUT `fade` + // (the DialogHost confirm modal HAS `fade`). :not(.fade) pins the + // page-local one. + var modal = page.Locator(".modal.show.d-block:not(.fade)"); + await Assertions.Expect(modal).ToBeVisibleAsync(); + await Assertions.Expect(modal.Locator("h6.modal-title")).ToHaveTextAsync("Edit Attribute"); + + // When editing, the Name input is rendered readonly (readonly="@editing"). + await Assertions.Expect( + modal.Locator("div.col-12:has(label:has-text('Name')) input.form-control")) + .ToHaveAttributeAsync("readonly", ""); + + // The Value input is label-anchored: its containing div carries the + // "Value" label. ("Data Source Ref" does not contain "Value", so the + // substring match resolves uniquely to the Value field.) + var valueInput = modal.Locator("div.col-12:has(label:has-text('Value')) input.form-control"); + await valueInput.FillAsync("42.5"); + + // Footer button text is "Save" when editing (vs "Add" when adding). + await modal.Locator(".modal-footer button.btn-success.btn-sm:has-text('Save')").ClickAsync(); + + // SaveAttribute persists then reloads, dismissing the modal. + await Assertions.Expect(modal).ToHaveCountAsync(0, new() { Timeout = 10_000 }); + + // The attribute table renders the value in a @effectiveValue + // cell (for a non-derived template effectiveValue == attr.Value), so the + // persisted "42.5" must appear in the Val row's value cell. Web-first wait + // covers the post-save reload. + await Assertions.Expect(page.Locator("table td.small:has-text('42.5')")) + .ToBeVisibleAsync(new() { Timeout = 10_000 }); + } + finally + { + await CliRunner.DeleteTemplateAsync(id); + // Defensive sweep by name in case the delete above was a no-op. + try + { + foreach (var leftover in await CliRunner.ListTemplateIdsByNamePrefixAsync(name)) + { + await CliRunner.DeleteTemplateAsync(leftover); + } + } + catch + { + // Best-effort — swallow to avoid masking the original test failure. + } + } + } + + [SkippableFact] + public async Task DeleteTemplate_WithInstance_IsBlocked() + { + Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason); + + // Mint a non-deployed instance referencing the fixture's template — that + // reference is sufficient for TemplateDeletionService to block the delete + // with the "instance(s) reference it" error. + var (instId, _) = await _cluster.CreateInstanceAsync(); + try + { + var page = await _fixture.NewAuthenticatedPageAsync(); + + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/templates/{_cluster.TemplateId}"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + // Header Delete button (btn-outline-danger btn-sm). + await page.ClickAsync("button.btn.btn-outline-danger.btn-sm:has-text('Delete')"); + + // Confirm via the global DialogHost modal's danger button (labelled + // "Delete"). This modal HAS the `fade` class — distinct from the + // page-local attribute modal. + var confirmBtn = page.Locator(".modal-footer .btn-danger:has-text('Delete')"); + await Assertions.Expect(confirmBtn).ToBeVisibleAsync(new() { Timeout = 5_000 }); + await confirmBtn.ClickAsync(); + + // The delete fails: DeleteTemplate surfaces result.Error on a toast and + // does NOT navigate. The error reads + // "Cannot delete template '{name}': {n} instance(s) reference it (...)". + await Assertions.Expect(page.Locator(".toast")) + .ToContainTextAsync("instance(s) reference it", new() { Timeout = 10_000 }); + + // The template still exists — the page stayed on the detail URL because + // the failed delete did not navigate away. + await Assertions.Expect(page) + .ToHaveURLAsync(new System.Text.RegularExpressions.Regex( + $"/design/templates/{_cluster.TemplateId}$")); + } + finally + { + // Remove this test's instance so the fixture template is instance-free + // again; the fixture's DisposeAsync sweeps the template + any leftovers. + await CliRunner.DeleteInstanceAsync(instId); + } + } }