From e82f26b97610b3f6d3a5d6f08553ccaa1376fbe2 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 19 Oct 2026 10:09:20 -0400 Subject: [PATCH] feat(ui): rename nests from Nest Info in Database mode The nest name box was always read-only, so a nest could only be renamed by Save As, which leaves the old copy behind. In Database mode Nest Info now edits the name (uploaded with the next Save), as does the saved-nest browser's Nest Information (saved immediately). File mode keeps it read-only because the file name is the nest name. Renaming to a name another active nest already has asks first, and declining reopens the dialog with the edits kept; a duplicate accepted there is not asked about again by the nest's first Save. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Data/NestNameConflicts.cs | 44 +++++++++++++++---- OpenNest.Server.Tests/NestSequenceTests.cs | 17 +++++++ OpenNest.Tests/Data/NestNameConflictsTests.cs | 20 +++++++++ .../Forms/EditNestInfoFormTests.cs | 42 +++++++++++++++++- .../Forms/SavedNestsFormTests.cs | 4 ++ OpenNest/Document.cs | 7 +++ .../Forms/Commands/NestStorageController.cs | 3 +- OpenNest/Forms/EditNestForm.cs | 33 ++++++++++++-- OpenNest/Forms/EditNestInfoForm.cs | 18 +++++++- OpenNest/Forms/MainForm.Nest.cs | 6 ++- OpenNest/Forms/NestRenameConfirmation.cs | 43 ++++++++++++++++++ OpenNest/Forms/SavedNestsForm.cs | 13 ++++-- docs/nest-storage.md | 12 ++++- 13 files changed, 241 insertions(+), 21 deletions(-) create mode 100644 OpenNest/Forms/NestRenameConfirmation.cs diff --git a/OpenNest.Data/NestNameConflicts.cs b/OpenNest.Data/NestNameConflicts.cs index 10356507..c745aaab 100644 --- a/OpenNest.Data/NestNameConflicts.cs +++ b/OpenNest.Data/NestNameConflicts.cs @@ -4,9 +4,9 @@ using System.Text.Json; namespace OpenNest.Data; /// -/// Duplicate-name check for a nest's first upload. Names may already be on posted CNC -/// programs and labels, so a nest is never renamed silently: callers offer the -/// replacement only when another active nest already has the exact same name. +/// Duplicate-name checks for a nest's first upload and for renames. Names may already be +/// on posted CNC programs and labels, so a nest is never renamed silently: callers offer +/// a replacement, or warn, only when another active nest has the exact same name. /// public static class NestNameConflicts { @@ -24,21 +24,42 @@ public static class NestNameConflicts try { - if (!await IsNameTakenAsync(repository, name, cancellationToken)) + if (!await IsNameTakenAsync(repository, name, cancellationToken: cancellationToken)) return null; var number = await repository.AllocateNestNumberAsync(year, cancellationToken); return new NestNameAllocation { Year = year, Number = number, Name = NestNames.Format(year, number) }; } - catch (Exception ex) when (ex is HttpRequestException or IOException or OperationCanceledException - or NotSupportedException or ArgumentException or JsonException or InvalidOperationException) + catch (Exception ex) when (IsCheckFailure(ex)) { return null; } } - /// True when an active (not deleted) server nest is named exactly . + /// + /// , or null when the server could not answer + /// (offline, too slow, or an older server), so the caller can go ahead without warning. + /// + public static async Task TryIsNameTakenAsync( + INestRepository repository, string name, Guid excludeId = default, + CancellationToken cancellationToken = default) + { + try + { + return await IsNameTakenAsync(repository, name, excludeId, cancellationToken); + } + catch (Exception ex) when (IsCheckFailure(ex)) + { + return null; + } + } + + /// + /// True when an active (not deleted) server nest other than + /// is named exactly . + /// public static async Task IsNameTakenAsync( - INestRepository repository, string name, CancellationToken cancellationToken = default) + INestRepository repository, string name, Guid excludeId = default, + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(repository); // The search is a case-insensitive substring match; page through it for the exact name. @@ -52,11 +73,16 @@ public static class NestNameConflicts Offset = offset, Limit = NestQuery.MaxLimit, }, cancellationToken); - if (page.Items.Any(r => !r.Deleted && string.Equals(r.Name, name, StringComparison.Ordinal))) + if (page.Items.Any(r => !r.Deleted && r.Id != excludeId + && string.Equals(r.Name, name, StringComparison.Ordinal))) return true; offset += page.Items.Count; if (page.Items.Count == 0 || offset >= page.Total) return false; } } + + private static bool IsCheckFailure(Exception ex) => + ex is HttpRequestException or IOException or OperationCanceledException + or NotSupportedException or ArgumentException or JsonException or InvalidOperationException; } diff --git a/OpenNest.Server.Tests/NestSequenceTests.cs b/OpenNest.Server.Tests/NestSequenceTests.cs index efe89267..7b814ff6 100644 --- a/OpenNest.Server.Tests/NestSequenceTests.cs +++ b/OpenNest.Server.Tests/NestSequenceTests.cs @@ -73,6 +73,23 @@ public sealed class NestSequenceTests Assert.Null(await NestNameConflicts.FindReplacementAsync(repository, NestNames.Format(2026, 40).ToLowerInvariant(), 2026)); } + [Fact] + public async Task Rename_WarnsOnlyAboutOtherActiveNests() + { + using var factory = new ServerFactory(); + Guid own; + using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) + { + own = database.Insert(Guid.NewGuid(), new NestRecord { Name = "Bracket job" }, [1]).Id; + database.Insert(Guid.NewGuid(), new NestRecord { Name = "Gusset job" }, [2]); + } + using var client = factory.CreateClient(); + var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); + + Assert.False(await NestNameConflicts.TryIsNameTakenAsync(repository, "Bracket job", own)); + Assert.True(await NestNameConflicts.TryIsNameTakenAsync(repository, "Gusset job", own)); + } + [Fact] public async Task Endpoint_RejectsYearsOutsideTheNameRange() { diff --git a/OpenNest.Tests/Data/NestNameConflictsTests.cs b/OpenNest.Tests/Data/NestNameConflictsTests.cs index c4393a27..f2b53ce2 100644 --- a/OpenNest.Tests/Data/NestNameConflictsTests.cs +++ b/OpenNest.Tests/Data/NestNameConflictsTests.cs @@ -45,6 +45,26 @@ public class NestNameConflictsTests Assert.False(await NestNameConflicts.IsNameTakenAsync(repository, "Job")); } + [Fact] + public async Task Rename_IgnoresTheNestsOwnRecord() + { + var repository = new NamedRepository("Job"); + var own = repository.Records[0].Id; + + Assert.False(await NestNameConflicts.IsNameTakenAsync(repository, "Job", own)); + Assert.True(await NestNameConflicts.IsNameTakenAsync(repository, "Job", Guid.NewGuid())); + } + + [Fact] + public async Task TryIsNameTaken_IsUnknownWhenTheServerCannotAnswer() + { + var repository = new NamedRepository("Job") { QueryFailure = new HttpRequestException("offline") }; + + Assert.Null(await NestNameConflicts.TryIsNameTakenAsync(repository, "Job")); + repository.QueryFailure = null; + Assert.True(await NestNameConflicts.TryIsNameTakenAsync(repository, "Job")); + } + [Fact] public async Task DuplicateBeyondTheFirstPage_IsFound() { diff --git a/OpenNest.WinForms.Tests/Forms/EditNestInfoFormTests.cs b/OpenNest.WinForms.Tests/Forms/EditNestInfoFormTests.cs index 8a550c39..bfd06286 100644 --- a/OpenNest.WinForms.Tests/Forms/EditNestInfoFormTests.cs +++ b/OpenNest.WinForms.Tests/Forms/EditNestInfoFormTests.cs @@ -107,7 +107,8 @@ public class EditNestInfoFormTests accept.Start(); // This is the live Nest Info entry point; it owns LoadNestInfo, // ShowDialog and SaveNestInfo after the actual OK button accepts. - editor.ShowNestInfoEditor(); + // An unchanged name never checks the server, so this completes without awaiting. + editor.ShowNestInfoEditorAsync().GetAwaiter().GetResult(); accept.Stop(); if (failure != null) ExceptionDispatchInfo.Capture(failure).Throw(); @@ -122,6 +123,45 @@ public class EditNestInfoFormTests } } + [Fact] + public void Name_IsReadOnlyUnlessRenameIsAllowed() + { + RunSta(() => + { + var nest = AcceptanceNest(); + using var form = new EditNestInfoForm(); + form.LoadNestInfo(nest); + var nameBox = Assert.IsType(form.Controls.Find("nameBox", true).Single()); + Assert.True(nameBox.ReadOnly); + + form.AllowRename = true; + Assert.False(nameBox.ReadOnly); + nameBox.Text = " Renamed job "; + form.SaveNestInfo(nest); + + Assert.Equal("Renamed job", nest.Name); + }); + } + + [Fact] + public void BlankName_DisablesOk() + { + RunSta(() => + { + using var form = new EditNestInfoForm { AllowRename = true }; + form.LoadNestInfo(AcceptanceNest()); + form.EnableCheck(); // The size box re-checks on a debounce timer. + var nameBox = Assert.IsType(form.Controls.Find("nameBox", true).Single()); + var ok = Assert.IsType