mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-08 09:05:44 -05:00
feat(storage): ask before a first save reuses another nest's name
Nests named offline from the per-PC counter can collide with names already on the server. Renaming them silently is not an option because the name may already be on posted CNC programs and labels, so the first Save of a nest to a server now checks for an active nest with exactly the same name and, only then, offers a freshly allocated name in an editable dialog (Cancel saves nothing). A failed or slow check keeps the current name, as before. Save As and pending-upload reconciliation are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3bc71a7e87
commit
c7df3f5e23
9 files changed
+299
-4
No files matched your search
@@ -0,0 +1,62 @@
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class NestNameConflicts
|
||||
{
|
||||
/// <summary>
|
||||
/// A freshly allocated name for <paramref name="year"/> when an active server nest
|
||||
/// already uses <paramref name="name"/>; null when the name is free, or when the
|
||||
/// check or the allocation fails, so the caller keeps the name it has.
|
||||
/// </summary>
|
||||
public static async Task<NestNameAllocation?> FindReplacementAsync(
|
||||
INestRepository repository, string name, int year, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
if (!await IsNameTakenAsync(repository, name, 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)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True when an active (not deleted) server nest is named exactly <paramref name="name"/>.</summary>
|
||||
public static async Task<bool> IsNameTakenAsync(
|
||||
INestRepository repository, string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
// The search is a case-insensitive substring match; page through it for the exact name.
|
||||
for (var offset = 0; ;)
|
||||
{
|
||||
var page = await repository.QueryAsync(new NestQuery
|
||||
{
|
||||
Search = name,
|
||||
Sort = NestSortField.Name,
|
||||
Descending = false,
|
||||
Offset = offset,
|
||||
Limit = NestQuery.MaxLimit,
|
||||
}, cancellationToken);
|
||||
if (page.Items.Any(r => !r.Deleted && string.Equals(r.Name, name, StringComparison.Ordinal)))
|
||||
return true;
|
||||
offset += page.Items.Count;
|
||||
if (page.Items.Count == 0 || offset >= page.Total)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,13 @@ public sealed class NestSaveSession
|
||||
private NestRecord? pendingCreateMetadata;
|
||||
private bool pendingCreateWasCopy;
|
||||
|
||||
/// <summary>
|
||||
/// True when a plain Save to <paramref name="serverUrl"/> would create a new record:
|
||||
/// nothing is bound on that server and no earlier upload is pending.
|
||||
/// </summary>
|
||||
public bool IsFirstSave(string? serverUrl) =>
|
||||
PendingCreateId == Guid.Empty && (RemoteId == Guid.Empty || ServerUrl != Normalize(serverUrl));
|
||||
|
||||
public void Bind(Guid id, string serverUrl, int revision)
|
||||
{
|
||||
if (revision < 0) throw new ArgumentOutOfRangeException(nameof(revision));
|
||||
|
||||
@@ -53,6 +53,26 @@ public sealed class NestSequenceTests
|
||||
Assert.Equal(Enumerable.Range(1, 40), numbers.Order());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DuplicateFirstSaveName_GetsAFreshNameOnlyForActiveExactMatches()
|
||||
{
|
||||
using var factory = new ServerFactory();
|
||||
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
|
||||
{
|
||||
database.Insert(Guid.NewGuid(), new NestRecord { Name = NestNames.Format(2026, 40) }, [1]);
|
||||
var deleted = database.Insert(Guid.NewGuid(), new NestRecord { Name = "Old job" }, [2]);
|
||||
database.SetDeleted(deleted.Id, true, deleted.Revision);
|
||||
}
|
||||
using var client = factory.CreateClient();
|
||||
var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString());
|
||||
|
||||
var replacement = await NestNameConflicts.FindReplacementAsync(repository, NestNames.Format(2026, 40), 2026);
|
||||
Assert.Equal(NestNames.Format(2026, 41), replacement?.Name);
|
||||
|
||||
Assert.Null(await NestNameConflicts.FindReplacementAsync(repository, "Old job", 2026));
|
||||
Assert.Null(await NestNameConflicts.FindReplacementAsync(repository, NestNames.Format(2026, 40).ToLowerInvariant(), 2026));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Endpoint_RejectsYearsOutsideTheNameRange()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
using OpenNest.Data;
|
||||
|
||||
namespace OpenNest.Tests.Data;
|
||||
|
||||
public class NestNameConflictsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task FreeName_IsKeptWithoutAllocating()
|
||||
{
|
||||
var repository = new NamedRepository("N26-223X", "Other N26-223");
|
||||
|
||||
Assert.Null(await NestNameConflicts.FindReplacementAsync(repository, "N26-223", 2026));
|
||||
Assert.Equal(0, repository.Allocations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExactDuplicate_OffersTheNextAllocatedName()
|
||||
{
|
||||
var repository = new NamedRepository("N26-223") { NextNumber = 31 };
|
||||
|
||||
var replacement = await NestNameConflicts.FindReplacementAsync(repository, "N26-223", 2026);
|
||||
|
||||
Assert.NotNull(replacement);
|
||||
Assert.Equal(2026, replacement!.Year);
|
||||
Assert.Equal(31, replacement.Number);
|
||||
Assert.Equal(NestNames.Format(2026, 31), replacement.Name);
|
||||
Assert.Equal(1, repository.Allocations);
|
||||
Assert.Equal("N26-223", repository.Queries[0].Search);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MatchIsExact_NotCaseFoldedOrSubstring()
|
||||
{
|
||||
var repository = new NamedRepository("n26-223", "N26-2234");
|
||||
|
||||
Assert.False(await NestNameConflicts.IsNameTakenAsync(repository, "N26-223"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeletedNestsDoNotCount()
|
||||
{
|
||||
var repository = new NamedRepository();
|
||||
repository.Records.Add(new NestRecord { Id = Guid.NewGuid(), Name = "Job", Deleted = true });
|
||||
|
||||
Assert.False(await NestNameConflicts.IsNameTakenAsync(repository, "Job"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DuplicateBeyondTheFirstPage_IsFound()
|
||||
{
|
||||
var names = Enumerable.Range(0, NestQuery.MaxLimit + 5).Select(i => $"{i:D4} Job").Append("Job");
|
||||
var repository = new NamedRepository(names.ToArray());
|
||||
|
||||
Assert.True(await NestNameConflicts.IsNameTakenAsync(repository, "Job"));
|
||||
Assert.Equal(2, repository.Queries.Count);
|
||||
Assert.Equal(NestQuery.MaxLimit, repository.Queries[1].Offset);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(false, true)]
|
||||
public async Task FailedCheckOrAllocation_KeepsTheCurrentName(bool queryFails, bool allocationFails)
|
||||
{
|
||||
var repository = new NamedRepository("Job")
|
||||
{
|
||||
QueryFailure = queryFails ? new HttpRequestException("offline") : null,
|
||||
AllocationFailure = allocationFails ? new NotSupportedException("old server") : null,
|
||||
};
|
||||
|
||||
Assert.Null(await NestNameConflicts.FindReplacementAsync(repository, "Job", 2026));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timeout_KeepsTheCurrentName()
|
||||
{
|
||||
var repository = new NamedRepository("Job");
|
||||
using var cancelled = new CancellationTokenSource();
|
||||
cancelled.Cancel();
|
||||
|
||||
Assert.Null(await NestNameConflicts.FindReplacementAsync(repository, "Job", 2026, cancelled.Token));
|
||||
}
|
||||
|
||||
private sealed class NamedRepository : INestRepository
|
||||
{
|
||||
public NamedRepository(params string[] names) =>
|
||||
Records.AddRange(names.Select(n => new NestRecord { Id = Guid.NewGuid(), Name = n }));
|
||||
|
||||
public List<NestRecord> Records { get; } = [];
|
||||
public List<NestQuery> Queries { get; } = [];
|
||||
public int NextNumber { get; set; } = 1;
|
||||
public int Allocations { get; private set; }
|
||||
public Exception? QueryFailure { get; set; }
|
||||
public Exception? AllocationFailure { get; set; }
|
||||
|
||||
public Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
Queries.Add(query);
|
||||
if (QueryFailure != null)
|
||||
throw QueryFailure;
|
||||
// Mirrors the server: case-insensitive substring search over active nests only.
|
||||
var matches = Records
|
||||
.Where(r => !r.Deleted && r.Name.Contains(query.NormalizedSearch, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
return Task.FromResult(new NestPage
|
||||
{
|
||||
Items = matches.Skip(query.Offset).Take(query.Limit).ToList(),
|
||||
Total = matches.Count,
|
||||
Offset = query.Offset,
|
||||
Limit = query.Limit,
|
||||
});
|
||||
}
|
||||
|
||||
public Task<int> AllocateNestNumberAsync(int year, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (AllocationFailure != null)
|
||||
throw AllocationFailure;
|
||||
Allocations++;
|
||||
return Task.FromResult(NextNumber++);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<byte[]?> GetFileAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<NestRecord> UploadAsync(byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<NestRecord> UpdateFileAsync(Guid id, byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task DeleteAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,23 @@ public class NestSaveSessionTests
|
||||
Assert.Equal(2, repository.Uploads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsFirstSave_OnlyWhenNothingIsBoundOrPendingOnThatServer()
|
||||
{
|
||||
var repository = new FakeRepository();
|
||||
var session = new NestSaveSession();
|
||||
Assert.True(session.IsFirstSave("http://shop:8090"));
|
||||
|
||||
await session.SaveAsync(repository, "http://shop:8090", new Nest("Job"), [1]);
|
||||
Assert.False(session.IsFirstSave("http://shop:8090/"));
|
||||
Assert.True(session.IsFirstSave("http://other:8090"));
|
||||
|
||||
repository.Fail = true;
|
||||
await Assert.ThrowsAsync<IOException>(() =>
|
||||
session.SaveAsync(repository, "http://other:8090", new Nest("Job"), [1]));
|
||||
Assert.False(session.IsFirstSave("http://other:8090"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FailedSaveAndServerSwitch_NeverOverwritePriorIdentity()
|
||||
{
|
||||
|
||||
@@ -22,6 +22,7 @@ namespace OpenNest
|
||||
public Guid PendingCreateId => remoteSession.PendingCreateId;
|
||||
public bool PendingCreateRequiresReconciliation => remoteSession.PendingCreateId != Guid.Empty && remoteSession.RequiresReload;
|
||||
public bool PendingCreateWasCopy => remoteSession.PendingCreateWasCopy;
|
||||
public bool IsFirstDatabaseSave(string serverUrl) => remoteSession.IsFirstSave(serverUrl);
|
||||
public void BindRemote(Guid id, string serverUrl) => remoteSession.Bind(id, serverUrl);
|
||||
|
||||
public void BindRemote(Guid id, string serverUrl, int revision) => remoteSession.Bind(id, serverUrl, revision);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Data;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.Properties;
|
||||
|
||||
namespace OpenNest.Forms.Commands
|
||||
{
|
||||
@@ -15,6 +17,8 @@ namespace OpenNest.Forms.Commands
|
||||
/// </summary>
|
||||
internal sealed class NestStorageController
|
||||
{
|
||||
private static readonly TimeSpan NameCheckTimeout = TimeSpan.FromSeconds(3);
|
||||
|
||||
private readonly Form owner;
|
||||
private readonly Action<string> showStatus;
|
||||
|
||||
@@ -93,6 +97,7 @@ namespace OpenNest.Forms.Commands
|
||||
private async Task SaveAsync(EditNestForm form, bool saveCopy, string copyName = null)
|
||||
{
|
||||
var originalName = form.Nest.Name;
|
||||
var renamed = false;
|
||||
SetSaving(true);
|
||||
try
|
||||
{
|
||||
@@ -109,6 +114,18 @@ namespace OpenNest.Forms.Commands
|
||||
}
|
||||
if (copyName != null)
|
||||
form.Nest.Name = copyName;
|
||||
else if (!saveCopy && form.Document.IsFirstDatabaseSave(serverUrl))
|
||||
{
|
||||
var chosen = await ChooseFirstSaveNameAsync(repository, form.Nest.Name);
|
||||
if (chosen is null)
|
||||
{
|
||||
if (!owner.IsDisposed)
|
||||
showStatus("Save cancelled");
|
||||
return;
|
||||
}
|
||||
renamed = chosen != originalName;
|
||||
form.Nest.Name = chosen;
|
||||
}
|
||||
var saved = await form.Document.SaveToDatabaseAsync(
|
||||
repository, serverUrl, saveCopy);
|
||||
if (!form.IsDisposed)
|
||||
@@ -122,6 +139,9 @@ namespace OpenNest.Forms.Commands
|
||||
// otherwise a later Save would rename the original nest.
|
||||
if (copyName != null && !form.Document.PendingCreateWasCopy)
|
||||
form.Nest.Name = originalName;
|
||||
// Likewise a duplicate-name rename only sticks once its upload may exist.
|
||||
if (renamed && form.Document.PendingCreateId == Guid.Empty)
|
||||
form.Nest.Name = originalName;
|
||||
if (!owner.IsDisposed)
|
||||
ShowError("save", ex);
|
||||
}
|
||||
@@ -131,6 +151,27 @@ namespace OpenNest.Forms.Commands
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The name for a nest's first upload: its own name unless another server nest
|
||||
/// already has it, in which case the user confirms or edits a newly allocated name.
|
||||
/// Null when the user cancels. A failed check keeps the current name.
|
||||
/// </summary>
|
||||
private async Task<string> ChooseFirstSaveNameAsync(INestRepository repository, string name)
|
||||
{
|
||||
var year = DateTime.Now.Year;
|
||||
NestNameAllocation replacement;
|
||||
using (var timeout = new CancellationTokenSource(NameCheckTimeout))
|
||||
replacement = await NestNameConflicts.FindReplacementAsync(repository, name, year, timeout.Token);
|
||||
if (replacement is null)
|
||||
return name;
|
||||
|
||||
if (year == Settings.Default.LastNestCreatedDate.Year)
|
||||
Settings.Default.NestNumber = System.Math.Max(Settings.Default.NestNumber, replacement.Number + 1);
|
||||
using var dlg = new NestNameForm(replacement.Name, "Save",
|
||||
$"A nest named \"{name}\" is already on the server. Save this nest as:");
|
||||
return dlg.ShowDialog(owner) == DialogResult.OK ? dlg.NestName : null;
|
||||
}
|
||||
|
||||
public void ShowError(string action, Exception ex) =>
|
||||
MessageBox.Show(owner, $"Could not {action} nest: {ex.Message}",
|
||||
"Nest Storage", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
|
||||
@@ -6,15 +6,16 @@ namespace OpenNest.Forms;
|
||||
|
||||
/// <summary>
|
||||
/// Database-mode Save As: asks for the name of the new nest copy, since there is no
|
||||
/// file dialog to supply one.
|
||||
/// file dialog to supply one. Save also uses it to rename a first upload whose name
|
||||
/// another server nest already has.
|
||||
/// </summary>
|
||||
public sealed class NestNameForm : Form
|
||||
{
|
||||
private readonly TextBox nameBox;
|
||||
|
||||
public NestNameForm(string currentName)
|
||||
public NestNameForm(string currentName, string title = "Save As", string prompt = "New nest name:")
|
||||
{
|
||||
Text = "Save As";
|
||||
Text = title;
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
ClientSize = new Size(380, 110);
|
||||
MinimizeBox = false;
|
||||
@@ -24,8 +25,9 @@ public sealed class NestNameForm : Form
|
||||
|
||||
var label = new Label
|
||||
{
|
||||
Text = "New nest name:",
|
||||
Text = prompt,
|
||||
AutoSize = true,
|
||||
MaximumSize = new Size(356, 0),
|
||||
Location = new Point(12, 16),
|
||||
};
|
||||
nameBox = new TextBox
|
||||
@@ -48,6 +50,10 @@ public sealed class NestNameForm : Form
|
||||
buttons.Controls.Add(cancel);
|
||||
buttons.Controls.Add(ok);
|
||||
|
||||
// A long prompt wraps; keep the name box below it.
|
||||
nameBox.Top = System.Math.Max(36, label.Top + label.PreferredHeight + 4);
|
||||
ClientSize = new Size(ClientSize.Width, nameBox.Bottom + 51);
|
||||
|
||||
Controls.Add(nameBox);
|
||||
Controls.Add(label);
|
||||
Controls.Add(buttons);
|
||||
|
||||
@@ -133,6 +133,15 @@ the nest. It resets when the year changes and is raised past each number the
|
||||
server hands out. Two PCs naming nests offline at the same time can still pick
|
||||
the same name; a nest's identity on the server is its `id`, never its name.
|
||||
|
||||
A nest's name may already be on posted CNC programs or labels, so it is never
|
||||
renamed silently. When **Save** (not Save As, and not the reconciliation of a
|
||||
pending upload) creates a nest's first record on the current server, it first
|
||||
searches active nests for one with exactly the same name (case-sensitive;
|
||||
deleted nests do not count, `NestNameConflicts`). Only on a match does it
|
||||
allocate the year's next name and show it in an editable dialog: OK uploads
|
||||
under the chosen name, Cancel saves nothing. If the check or the allocation
|
||||
fails or takes longer than 3 seconds, the nest uploads under its current name.
|
||||
|
||||
There is no authentication; this is a LAN-only service. Do not expose it
|
||||
outside the shop network without adding one.
|
||||
|
||||
|
||||
Reference in new issue
Block a user