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