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