fix(browser): integrate metadata-safe nest information edits

This commit is contained in:
aj committed 2026-10-16 06:52:33 -04:00
commit 697b6492c7
5 files changed
+348 -8

No files matched your search

+16
View File
@@ -9,6 +9,22 @@ namespace OpenNest.Data;
/// </summary>
public static class NestRecordFactory
{
/// <summary>Overlays authoritative current metadata on an archived nest before editing.</summary>
public static void ApplyMetadata(Nest nest, NestRecord record)
{
ArgumentNullException.ThrowIfNull(nest);
ArgumentNullException.ThrowIfNull(record);
nest.Name = record.Name;
nest.Customer = record.Customer;
nest.Notes = record.Comments;
nest.Status = record.Status;
nest.MadeBy = record.MadeBy;
nest.DateLastModified = record.DateModified;
nest.DateCreated = record.DateCreated;
nest.Material = NestMaterialSelection.Apply(nest.Material, record.Material);
nest.Thickness = record.Thickness;
}
/// <summary>
/// Builds a record for uploading <paramref name="nest"/>. <paramref name="id"/> is the
/// client-tracked id: <see cref="System.Guid.Empty"/> for a first save (the server
@@ -6,6 +6,52 @@ namespace OpenNest.Tests.Data;
public class NestRecordFactoryTests
{
[Theory]
[InlineData("Steel", "Aluminum", false)]
[InlineData("Aluminum", "Aluminum", true)]
public void ApplyMetadata_UsesAllCurrentFields_AndPreservesMatchingMaterialProperties(
string archiveMaterial, string currentMaterial, bool sameMaterial)
{
var nest = new Nest("Old archive")
{
Material = new Material(archiveMaterial, "Test grade", 0.1),
Thickness = 0.125,
DateCreated = new DateTime(2025, 1, 1),
};
var plate = nest.CreatePlate();
var current = new NestRecord
{
Name = "Current name",
Customer = "Current customer",
Comments = "Current notes",
Material = currentMaterial,
Thickness = 0.375,
DateCreated = new DateTime(2026, 1, 1),
DateModified = new DateTime(2026, 2, 1),
Status = NestStatus.HasBeenCut,
MadeBy = "Operator",
};
NestRecordFactory.ApplyMetadata(nest, current);
var roundTrip = NestRecordFactory.FromNest(nest, Guid.Empty, 0);
Assert.Equal(current.Material, roundTrip.Material);
Assert.Equal(current.Thickness, roundTrip.Thickness);
Assert.Equal(current.DateCreated, roundTrip.DateCreated);
Assert.Equal(current.DateModified, roundTrip.DateModified);
Assert.Equal(current.Name, roundTrip.Name);
Assert.Equal(current.Customer, roundTrip.Customer);
Assert.Equal(current.Comments, roundTrip.Comments);
Assert.Equal(current.Status, roundTrip.Status);
Assert.Equal(current.MadeBy, roundTrip.MadeBy);
if (sameMaterial)
{
Assert.Equal("Test grade", nest.Material.Grade);
Assert.Equal(0.1, nest.Material.Density);
}
else
Assert.NotEqual("Test grade", nest.Material.Grade);
Assert.Same(plate, Assert.Single(nest.Plates));
}
[Fact]
public void FromNest_CountsPlatesAndNonCutoffParts()
{
@@ -145,6 +145,140 @@ public class SavedNestsFormTests
"The saved-nest dialog test did not complete.");
}
[Theory]
[InlineData(true, false)]
[InlineData(false, false)]
[InlineData(true, true)]
public void NestInformation_SavesOnlyOnAccept_AndRefusesStaleWrites(bool accept, bool conflict)
{
StaTestThread.Run(() =>
{
// PerformClick outside Application.Run needs the real UI dispatcher installed.
using var context = new WindowsFormsSynchronizationContext();
var record = Record("Current name");
record.Revision = 3;
record.Comments = "Current metadata notes";
record.Status = NestStatus.ToBeCut;
record.Material = "Aluminum";
record.Thickness = 0.375;
record.DateCreated = new DateTime(2026, 1, 1);
var repository = new FakeRepository(record) { Conflict = conflict };
repository.Files[record.Id] = NestFile((Quantity: 2, Parts: 3));
record.FileSize = repository.Files[record.Id].Length;
var original = repository.Files[record.Id].ToArray();
using var form = new SavedNestsForm(repository, "http://nest-server:5000");
form.Show();
PumpUntil(() => Field<DataGridView>(form, "nestGrid").Rows.Count == 1, "the nest page");
SynchronizationContext.SetSynchronizationContext(context);
var shown = false;
form.ShowNestInfoDialog = dialog =>
{
shown = true;
Assert.Equal(record.Name, dialog.NestName);
Assert.Equal(record.Comments, dialog.Notes);
Assert.Equal(NestStatus.ToBeCut, dialog.Status);
Assert.Equal("Aluminum", dialog.MaterialName);
Assert.Equal(0.375, dialog.Thickness);
Assert.Equal(new DateTime(2026, 1, 1), dialog.DateCreated);
dialog.Customer = "Updated customer";
dialog.Notes = "Updated notes";
dialog.Status = NestStatus.HasBeenCut;
dialog.MadeBy = "Operator";
return accept ? DialogResult.OK : DialogResult.Cancel;
};
var button = Field<Button>(form, "infoButton");
Assert.True(button.Enabled);
Assert.Contains(Field<DataGridView>(form, "nestGrid").ContextMenuStrip!.Items.Cast<ToolStripItem>(),
item => item.Text == "Nest Information...");
button.PerformClick();
PumpUntil(() => shown && form.Enabled, "the information edit");
Assert.True(shown);
Assert.True(form.Enabled);
Assert.Equal(DialogResult.None, form.DialogResult);
Assert.Equal(Guid.Empty, form.SelectedId);
Assert.Equal(accept ? 1 : 0, repository.UpdateAttempts);
if (accept && !conflict)
{
Assert.Equal(3, repository.ExpectedRevision);
Assert.Equal(4, record.Revision);
Assert.Equal("Updated customer", record.Customer);
Assert.Equal("Updated notes", record.Comments);
using var stream = new MemoryStream(repository.Files[record.Id]);
var nest = new NestReader(stream).Read();
Assert.Equal("Updated customer", nest.Customer);
Assert.Equal("Updated notes", nest.Notes);
Assert.Equal(NestStatus.HasBeenCut, nest.Status);
Assert.Equal("Operator", nest.MadeBy);
Assert.Equal("Aluminum", nest.Material.Name);
Assert.Equal(0.375, nest.Thickness);
Assert.Equal(new DateTime(2026, 1, 1), nest.DateCreated);
Assert.Equal("Aluminum", record.Material);
Assert.Equal(0.375, record.Thickness);
Assert.Equal(new DateTime(2026, 1, 1), record.DateCreated);
Assert.Single(nest.Plates);
Assert.Equal(2, nest.Plates[0].Quantity);
Assert.Equal(3, nest.Plates[0].Parts.Count);
Assert.Equal("Updated customer", Field<DataGridView>(form, "nestGrid").Rows[0].Cells["Customer"].Value);
}
else
{
Assert.Equal(original, repository.Files[record.Id]);
Assert.Equal(3, record.Revision);
if (conflict)
Assert.Contains("409", Field<Label>(form, "summaryLabel").Text);
}
}, TestTimeout, "The information edit did not complete.");
}
[Fact]
public void NestInformation_PendingOperationBlocksReentryAndClose_AndRefreshesWarmedDetails()
{
StaTestThread.Run(() =>
{
// PerformClick outside Application.Run needs the real UI dispatcher installed.
using var context = new WindowsFormsSynchronizationContext();
var record = Record("Busy job");
record.Revision = 1;
var repository = new FakeRepository(record);
repository.Files[record.Id] = NestFile((Quantity: 1, Parts: 2));
record.FileSize = repository.Files[record.Id].Length;
using var form = new SavedNestsForm(repository, "http://nest-server:5000");
form.Show();
PumpUntil(() => Field<DataGridView>(form, "platesGrid").Rows.Count == 1, "warmed details");
var oldDetails = Field<NestDetailsSession>(form, "details");
repository.ReadGate = new TaskCompletionSource<bool>();
repository.SaveGate = new TaskCompletionSource<bool>();
SynchronizationContext.SetSynchronizationContext(context);
var shown = false;
form.ShowNestInfoDialog = dialog =>
{
shown = true;
dialog.Notes = "Saved after wait";
return DialogResult.OK;
};
var operation = form.EditSelectedInfoAsync();
Assert.False(operation.IsCompleted);
Assert.False(form.Enabled);
Assert.False(Field<ToolStripMenuItem>(form, "infoMenuItem").Enabled);
form.Close();
Assert.False(form.IsDisposed);
Assert.True(form.EditSelectedInfoAsync().IsCompletedSuccessfully);
Assert.False(shown);
repository.ReadGate.SetResult(true);
PumpUntil(() => repository.UpdateAttempts == 1, "pending save");
Assert.True(shown);
Assert.False(operation.IsCompleted);
Assert.False(form.Enabled);
repository.SaveGate.SetResult(true);
PumpUntil(() => operation.IsCompleted, "completed save");
Assert.Null(operation.Exception);
Assert.True(form.Enabled);
Assert.NotSame(oldDetails, Field<NestDetailsSession>(form, "details"));
PumpUntil(() => Field<DataGridView>(form, "platesGrid").Rows.Count == 1, "reloaded details");
Assert.Equal("Saved after wait", record.Comments);
}, TestTimeout, "The pending information edit did not complete.");
}
private static NestRecord Record(string name) =>
new() { Id = Guid.NewGuid(), Name = name, SavedAt = DateTime.Now };
@@ -203,8 +337,43 @@ public class SavedNestsFormTests
public FakeRepository(params NestRecord[] records) => _records = records;
public TaskCompletionSource<bool>? ReadGate { get; set; }
public TaskCompletionSource<bool>? SaveGate { get; set; }
public bool Conflict { get; set; }
public int UpdateAttempts { get; private set; }
public int ExpectedRevision { get; private set; }
public Dictionary<Guid, byte[]> Files { get; } = new();
public Task<byte[]?> GetRevisionFileAsync(Guid id, int revisionNumber, CancellationToken cancellationToken = default) =>
GetFileAsync(id, cancellationToken);
public Task<NestRevisionPage> GetRevisionsAsync(Guid id, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) =>
Task.FromResult(new NestRevisionPage());
public async Task<NestRecord> UpdateFileAsync(Guid id, byte[] bytes, NestRecord metadata, int expectedRevision,
CancellationToken cancellationToken = default)
{
UpdateAttempts++;
ExpectedRevision = expectedRevision;
if (SaveGate != null) await SaveGate.Task;
if (Conflict) throw new IOException("409: changed on another PC");
var current = _records.Single(r => r.Id == id);
Assert.Equal(current.Revision, expectedRevision);
current.Revision++;
current.Name = metadata.Name;
current.Customer = metadata.Customer;
current.Comments = metadata.Comments;
current.Material = metadata.Material;
current.Thickness = metadata.Thickness;
current.Status = metadata.Status;
current.MadeBy = metadata.MadeBy;
current.DateModified = metadata.DateModified;
current.DateCreated = metadata.DateCreated;
current.FileSize = bytes.Length;
Files[id] = bytes;
return current;
}
public Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default) =>
Task.FromResult(new NestPage
{
@@ -220,8 +389,11 @@ public class SavedNestsFormTests
public Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public async Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default)
{
if (ReadGate != null) await ReadGate.Task;
return _records.SingleOrDefault(r => r.Id == id);
}
public Task<NestRecord> UploadAsync(byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
+101 -6
View File
@@ -18,7 +18,7 @@ namespace OpenNest.Forms;
/// server one bounded page at a time; filtering, sorting and paging run on the server
/// through <see cref="NestBrowseSession"/>. The lower tabs show plates and drawings
/// from <see cref="NestDetailsSession"/> and on-demand version history beside a plate
/// preview. This form renders the selected nest without editing its archive.
/// preview. Nest Information edits a detached snapshot through guarded database saves.
/// </summary>
public sealed class SavedNestsForm : Form
{
@@ -63,6 +63,11 @@ public sealed class SavedNestsForm : Form
private readonly ToolStripMenuItem openMenuItem;
private readonly ToolStripMenuItem deleteMenuItem;
private bool deleting;
private bool editingInfo;
private readonly Button infoButton;
private readonly ToolStripMenuItem infoMenuItem;
internal Func<EditNestInfoForm, DialogResult> ShowNestInfoDialog { get; set; }
private IReadOnlyList<Plate> previewPlates = Array.Empty<Plate>();
private int previewIndex = -1;
@@ -143,7 +148,8 @@ public sealed class SavedNestsForm : Form
openMenuItem = new ToolStripMenuItem("Open", null, (_, _) => OpenSelected()) { ShortcutKeyDisplayString = "Enter" };
deleteMenuItem = new ToolStripMenuItem("Delete...", null, async (_, _) => await DeleteSelectedAsync());
var refreshMenuItem = new ToolStripMenuItem("Refresh", null, async (_, _) => await RunAsync(session.RefreshAsync));
menu.Items.AddRange(new ToolStripItem[] { openMenuItem, deleteMenuItem, new ToolStripSeparator(), refreshMenuItem });
infoMenuItem = new ToolStripMenuItem("Nest Information...", null, async (_, _) => await EditSelectedInfoAsync());
menu.Items.AddRange(new ToolStripItem[] { openMenuItem, infoMenuItem, deleteMenuItem, new ToolStripSeparator(), refreshMenuItem });
nestGrid.ContextMenuStrip = menu;
// Details of the highlighted nest.
@@ -237,7 +243,14 @@ public sealed class SavedNestsForm : Form
SplitterWidth = 6,
};
split.Panel1.Controls.Add(nestGrid);
split.Panel1.Controls.Add(SectionLabel("Nests", DockStyle.Top));
infoButton = new Button { Text = "Nest Information...", AutoSize = true, Dock = DockStyle.Right };
infoButton.Click += async (_, _) => await EditSelectedInfoAsync();
var nestsHeader = new Panel { Dock = DockStyle.Top, Height = 36 };
nestsHeader.Controls.Add(infoButton);
nestsHeader.Controls.Add(SectionLabel("Nests", DockStyle.Left));
split.Panel1.Controls.Add(nestsHeader);
ShowNestInfoDialog = dialog => dialog.ShowDialog(this);
FormClosing += (_, e) => e.Cancel = editingInfo;
split.Panel2.Controls.Add(detailsSplit);
split.Panel2.Controls.Add(detailsHeader);
@@ -322,6 +335,8 @@ public sealed class SavedNestsForm : Form
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
if (editingInfo)
return true;
switch (keyData)
{
case Keys.Enter when searchBox.Focused:
@@ -492,9 +507,11 @@ public sealed class SavedNestsForm : Form
private void UpdateCommands()
{
var hasSelection = SelectedNestId() != null;
var hasSelection = SelectedNestId() != null && !editingInfo && !deleting;
openMenuItem.Enabled = hasSelection;
deleteMenuItem.Enabled = hasSelection && !deleting;
infoButton.Enabled = hasSelection;
infoMenuItem.Enabled = hasSelection;
deleteMenuItem.Enabled = hasSelection;
}
private void UpdateTitle(string summary)
@@ -759,7 +776,7 @@ public sealed class SavedNestsForm : Form
private void OpenSelected()
{
if (SelectedNestId() is not Guid id)
if (editingInfo || SelectedNestId() is not Guid id)
return;
SelectedId = id;
DialogResult = DialogResult.OK;
@@ -801,6 +818,84 @@ public sealed class SavedNestsForm : Form
}
}
/// <summary>Edits a detached current snapshot without opening an MDI document.</summary>
internal async Task EditSelectedInfoAsync()
{
if (editingInfo || deleting || SelectedNestId() is not Guid id)
return;
summaryLabel.Text = "Loading nest information...";
editingInfo = true;
Enabled = false;
UpdateCommands();
try
{
var snapshot = await NestHistorySession.ReadCurrentAsync(repository, id, serverUrl);
if (IsDisposed) return;
if (snapshot.Metadata.Revision < 1)
throw new NotSupportedException("Upgrade the nest server and refresh before editing nest information.");
var nest = await Task.Run(() =>
{
using var source = new MemoryStream(snapshot.Archive);
var loaded = new NestReader(source).Read();
// Metadata-only revisions can be newer than the archived fields.
NestRecordFactory.ApplyMetadata(loaded, snapshot.Metadata);
return loaded;
});
if (IsDisposed) return;
using var dialog = new EditNestInfoForm();
dialog.LoadNestInfo(nest);
if (ShowNestInfoDialog(dialog) != DialogResult.OK)
return;
// Same default propagation as Nest > Nest Info in the open editor.
using var manager = new PlateManager(nest);
manager.ChangePlateDefaults(() => dialog.SaveNestInfo(nest));
nest.DateLastModified = DateTime.Now;
summaryLabel.Text = "Saving nest information...";
var bytes = await Task.Run(() =>
{
using var output = new MemoryStream();
new NestWriter(nest).Write(output);
return output.ToArray();
});
var save = new NestSaveSession();
save.Bind(id, snapshot.ServerUrl, snapshot.Metadata.Revision);
summaryLabel.Text = "Sending nest information...";
var saved = await save.SaveAsync(repository, snapshot.ServerUrl, nest, bytes);
var verified = await NestHistorySession.ReadCurrentAsync(repository, id, snapshot.ServerUrl);
if (verified.Metadata.Revision != saved.Revision
|| !System.Linq.Enumerable.SequenceEqual(bytes, verified.Archive))
throw new InvalidDataException("Could not verify the saved nest information.");
if (IsDisposed) return;
// Drop archive previews and embedded history, including prefetched snapshots.
details.Dispose();
details = new NestDetailsSession(LoadDetailsAsync);
historyView?.Dispose();
historyView = null;
historyId = null;
ShowDetailsMessage("");
await RunAsync(session.RefreshAsync);
foreach (DataGridViewRow row in nestGrid.Rows)
if (row.Cells[IdColumn].Value is Guid rowId && rowId == id)
nestGrid.CurrentCell = row.Cells[0];
}
catch (Exception ex)
{
if (!IsDisposed)
summaryLabel.Text = $"Could not edit nest information: {ex.Message} Refresh and reopen Nest Information before trying again; the save may already have reached the server.";
}
finally
{
editingInfo = false;
if (!IsDisposed)
{
Enabled = true;
if (summaryLabel.Text == "Loading nest information...")
summaryLabel.Text = session.Summary;
UpdateCommands();
}
}
}
private async Task DeleteSelectedAsync()
{
if (deleting || SelectedNestId() is not Guid id)
+11
View File
@@ -28,6 +28,17 @@ The desktop app can save nests two ways:
previous one. On the Versions tab, **Open read-only...** previews one snapshot
without binding an editable document, and **Download selected version...** saves
its exact archived bytes.
Select a nest and click **Nest Information...** above the list (or use the
row's right-click menu) to edit the same information as **Nest > Nest Info**
without opening the nest in the main editor. **OK** saves immediately to the
same database ID as a new version, then refreshes the list, preview and history;
**Cancel** makes no change. The dialog starts with the latest saved metadata.
Plate defaults follow the same rules as the open editor: populated sheets are
not resized or re-nested. This action requires a version-aware server and
refuses to overwrite a newer save from another PC. After a conflict or network
error, refresh and reopen the information dialog before trying again; a lost
reply can mean the save already reached the server. An already-open editor is
not modified; reload it before saving further changes to that same nest.
A separate confirmed **Restore selected version...** creates a new current
revision of the same ID and keeps all intervening versions. **Delete...** hides
the record from ordinary browsing but retains every archive; **Deleted nests...**