feat(storage): record material grade on server nests and pick it in Nest Info

Nest records gain a materialGrade field (server schema v8, blank for existing
rows) that is searched, sorted and versioned like the other metadata. The
Nest Info dialog gets a grade picker listing the machine configs' grades for
the chosen material; density survives only an unchanged name and grade. A
blank recorded grade keeps the archive's grade while the name matches, so
records saved before v8 do not lose it on open. Saved Nests shows a Grade column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-20 23:37:57 -04:00
1 parent 11b6032459
commit 7bf753ce17
21 files changed
+427 -61

No files matched your search

+34 -11
View File
@@ -1,25 +1,48 @@
namespace OpenNest.Data;
/// <summary>
/// Chooses the nest material when the Nest Info dialog is accepted. The dialog
/// edits only the material name, so grade and density must come from the
/// material the nest already has.
/// Chooses the nest material from a material name and grade, as edited in the
/// Nest Info dialog or recorded on the nest server. Density is not edited, so it
/// must come from the material the nest already has.
/// </summary>
public static class NestMaterialSelection
{
/// <summary>
/// Returns a new material named <paramref name="selectedName"/>. When that
/// name matches the current material's name by <see cref="SharedListNames.Key"/>,
/// the current grade and density are kept; any other name gets a name-only
/// material. The current material is never modified or returned.
/// Returns a new material named <paramref name="selectedName"/> with grade
/// <paramref name="selectedGrade"/>. When both match the current material by
/// <see cref="SharedListNames.Key"/>, the current density is kept; any other
/// name or grade gets no density. The current material is never modified or returned.
/// </summary>
public static Material Apply(Material? current, string? selectedName)
public static Material Apply(Material? current, string? selectedName, string? selectedGrade)
{
var name = selectedName ?? "";
var grade = selectedGrade ?? "";
if (current != null && SharedListNames.Key(current.Name) == SharedListNames.Key(name))
return new Material(name, current.Grade, current.Density);
if (
current != null
&& SharedListNames.Key(current.Name) == SharedListNames.Key(name)
&& SharedListNames.Key(current.Grade) == SharedListNames.Key(grade)
)
return new Material(name, grade, current.Density);
return new Material(name);
return new Material(name, grade);
}
/// <summary>
/// Applies server metadata to an archived material. A blank recorded grade means
/// the record predates grades, so the archive's grade stands while the name is
/// unchanged; every edit rewrites the archive too, so a cleared grade is blank there as well.
/// </summary>
public static Material ApplyRecorded(Material? current, string? recordedName, string? recordedGrade)
{
var grade = recordedGrade ?? "";
if (
grade.Length == 0
&& current != null
&& SharedListNames.Key(current.Name) == SharedListNames.Key(recordedName)
)
grade = current.Grade ?? "";
return Apply(current, recordedName, grade);
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ public sealed class NestQuery
public const int MaxSearchLength = 200;
/// <summary>
/// Case-insensitive substring matched against name, customer, material, made by,
/// Case-insensitive substring matched against name, customer, material, grade, made by,
/// comments and status (stored and display names). Blank means no filter.
/// </summary>
public string Search { get; init; } = "";
+3
View File
@@ -29,6 +29,9 @@ public sealed class NestRecord
public string Material { get; set; } = "";
/// <summary>Material grade; blank when none was chosen or the server predates grades.</summary>
public string MaterialGrade { get; set; } = "";
public double Thickness { get; set; }
/// <summary>quote | toBeCut | hasBeenCut (camelCase on the wire, case-insensitive readers).</summary>
+2 -1
View File
@@ -21,7 +21,7 @@ public static class NestRecordFactory
nest.MadeBy = record.MadeBy;
nest.DateLastModified = record.DateModified;
nest.DateCreated = record.DateCreated;
nest.Material = NestMaterialSelection.Apply(nest.Material, record.Material);
nest.Material = NestMaterialSelection.ApplyRecorded(nest.Material, record.Material, record.MaterialGrade);
nest.Thickness = record.Thickness;
}
@@ -44,6 +44,7 @@ public static class NestRecordFactory
DateCreated = nest.DateCreated,
DateModified = nest.DateLastModified,
Material = nest.Material?.Name ?? "",
MaterialGrade = nest.Material?.Grade ?? "",
Thickness = nest.Thickness,
Status = nest.Status,
// The trailing empty plate is the editor's new-plate slot, not a plate of the nest.
+2 -2
View File
@@ -127,7 +127,7 @@ public sealed class NestSaveSession
private static bool SameEditableMetadata(NestRecord a, NestRecord b) =>
a.Name == b.Name && a.Customer == b.Customer && a.DateCreated == b.DateCreated
&& a.DateModified == b.DateModified && a.Material == b.Material && a.Thickness == b.Thickness
&& a.Status == b.Status && a.PlateCount == b.PlateCount && a.PartCount == b.PartCount
&& a.DateModified == b.DateModified && a.Material == b.Material && a.MaterialGrade == b.MaterialGrade
&& a.Thickness == b.Thickness && a.Status == b.Status && a.PlateCount == b.PlateCount && a.PartCount == b.PartCount
&& a.Comments == b.Comments && a.MadeBy == b.MadeBy;
}
+1
View File
@@ -16,4 +16,5 @@ public enum NestSortField
MadeBy,
Comments,
FileSize,
MaterialGrade,
}
@@ -21,7 +21,7 @@ public sealed class DrawingLibraryDatabaseTests
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
Assert.Empty(database.QueryDrawings(new DrawingLibraryQuery { IncludeArchived = true }).Items);
Assert.Equal(before, NestDigest(factory.DatabasePath));
Assert.Equal(7L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(8L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
@@ -42,7 +42,7 @@ public sealed class DrawingLibraryDatabaseTests
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
{
Assert.Equal(7L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(8L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(before, NestDigest(factory.DatabasePath));
// Two distinct blobs across four revisions: the bracket's notes edit and the
// plate both reuse the bracket's first package.
@@ -79,7 +79,7 @@ public sealed class DrawingLibraryDatabaseTests
{
using var factory = new ServerFactory();
using (new NestDatabase(factory.DatabasePath, pooling: false)) { }
Execute(factory.DatabasePath, "PRAGMA user_version=8");
Execute(factory.DatabasePath, "PRAGMA user_version=9");
Assert.Throws<InvalidOperationException>(() => new NestDatabase(factory.DatabasePath, pooling: false));
}
@@ -22,7 +22,7 @@ public sealed class DrawingSetDatabaseTests
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
Assert.Empty(database.QueryDrawingSets(new DrawingSetQuery { IncludeArchived = true }).Items);
Assert.Equal(before, Digest(factory.DatabasePath));
Assert.Equal(7L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(8L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
@@ -47,7 +47,7 @@ public sealed class DrawingSetDatabaseTests
Assert.Equal(before, Digest(factory.DatabasePath));
Execute(factory.DatabasePath, "DROP TABLE ix_drawing_set_members_drawing");
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(7L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(8L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
[Fact]
@@ -0,0 +1,106 @@
using Microsoft.Data.Sqlite;
using OpenNest.Data;
namespace OpenNest.Server.Tests;
public sealed class NestMaterialGradeTests
{
[Fact]
public void Grade_RoundTripsThroughInsertUpdateAndHistory()
{
using var factory = new ServerFactory();
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
var id = Guid.NewGuid();
var created = database.Insert(id, Record("Job", "Stainless", "304"), [1]);
Assert.Equal("304", created.MaterialGrade);
var updated = database.UpdateMetadata(id, Record("Job", "Stainless", "316"), created.Revision)!;
Assert.Equal("316", updated.MaterialGrade);
Assert.Equal(created.Revision + 1, updated.Revision);
var history = database.History(id, 0, 10)!;
Assert.Equal(["316", "304"], history.Items.Select(r => r.MaterialGrade));
}
[Fact]
public void GradeOnlyChange_IsNotANoOp()
{
using var factory = new ServerFactory();
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
var id = Guid.NewGuid();
var created = database.Insert(id, Record("Job", "Stainless", "304"), [1]);
var unchanged = database.UpdateMetadata(id, Record("Job", "Stainless", "304"), created.Revision)!;
var changed = database.UpdateMetadata(id, Record("Job", "Stainless", "316"), created.Revision)!;
Assert.Equal(created.Revision, unchanged.Revision);
Assert.Equal(created.Revision + 1, changed.Revision);
}
[Fact]
public void Query_SearchesAndSortsByGrade()
{
using var factory = new ServerFactory();
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
database.Insert(Guid.NewGuid(), Record("A", "Aluminum", "6061"), [1]);
database.Insert(Guid.NewGuid(), Record("B", "Aluminum", "5052"), [1]);
database.Insert(Guid.NewGuid(), Record("C", "Stainless", "304"), [1]);
var found = database.Query(new NestQuery { Search = "5052" });
Assert.Equal("B", Assert.Single(found.Items).Name);
var sorted = database.Query(new NestQuery { Sort = NestSortField.MaterialGrade, Descending = false });
Assert.Equal(["C", "B", "A"], sorted.Items.Select(r => r.Name));
}
[Fact]
public void Version7Database_GainsBlankGradeAndKeepsRecords()
{
using var factory = new ServerFactory();
var id = Guid.NewGuid();
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
database.Insert(id, Record("Legacy", "Stainless", "304"), [1]);
Execute(factory.DatabasePath, """
ALTER TABLE nests DROP COLUMN materialGrade;
ALTER TABLE nest_revisions DROP COLUMN materialGrade;
PRAGMA user_version=7;
""");
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
var record = upgraded.Get(id)!;
Assert.Equal("Stainless", record.Material);
Assert.Equal("", record.MaterialGrade);
Assert.Equal("", Assert.Single(upgraded.History(id, 0, 10)!.Items).MaterialGrade);
Assert.Equal(8L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
private static NestRecord Record(string name, string material, string grade) => new()
{
Name = name,
Material = material,
MaterialGrade = grade,
Thickness = 0.25,
DateCreated = new DateTime(2026, 1, 1),
DateModified = new DateTime(2026, 1, 2),
};
private static void Execute(string path, string sql)
{
using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = sql;
command.ExecuteNonQuery();
}
private static object? Scalar(string path, string sql)
{
using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = sql;
return command.ExecuteScalar();
}
}
+2
View File
@@ -321,6 +321,7 @@ public sealed class NestQueryTests : IDisposable
DateCreated = new DateTime(2026, 1, 1 + index % 28, 8, 0, 0, DateTimeKind.Unspecified),
DateModified = new DateTime(2026, 1, 2 + index % 27, 9, 0, 0, DateTimeKind.Unspecified),
Material = index % 3 == 0 ? "Synthetic steel" : "Synthetic alloy",
MaterialGrade = index % 4 == 0 ? "" : $"Grade {index % 5}",
Thickness = 0.25 + index % 3 * 0.125,
Status = statuses[index % statuses.Length],
PlateCount = 1 + index % 4,
@@ -362,6 +363,7 @@ public sealed class NestQueryTests : IDisposable
NestSortField.MadeBy => Text(r => r.MadeBy),
NestSortField.Comments => Text(r => r.Comments),
NestSortField.FileSize => (a, b) => a.FileSize.CompareTo(b.FileSize),
NestSortField.MaterialGrade => Text(r => r.MaterialGrade),
_ => throw new ArgumentOutOfRangeException(nameof(field)),
};
var sorted = records.ToList();
+29 -9
View File
@@ -149,11 +149,11 @@ public sealed partial class NestDatabase : IDisposable
command.Transaction = transaction;
command.CommandText = """
INSERT INTO nests (id, name, customer, dateCreated, dateModified, material,
thickness, status, plateCount, partCount, comments, madeBy, fileSize,
savedAt, file)
materialGrade, thickness, status, plateCount, partCount, comments, madeBy,
fileSize, savedAt, file)
VALUES ($id, $name, $customer, $dateCreated, $dateModified, $material,
$thickness, $status, $plateCount, $partCount, $comments, $madeBy,
$fileSize, $savedAt, $file)
$materialGrade, $thickness, $status, $plateCount, $partCount, $comments,
$madeBy, $fileSize, $savedAt, $file)
""";
AddRecordParameters(command, id, record, file, updateFile: true);
command.ExecuteNonQuery();
@@ -199,7 +199,7 @@ public sealed partial class NestDatabase : IDisposable
UPDATE nests SET
name = $name, customer = $customer, dateCreated = $dateCreated,
dateModified = $dateModified, material = $material,
thickness = $thickness, status = $status, plateCount = $plateCount,
materialGrade = $materialGrade, thickness = $thickness, status = $status, plateCount = $plateCount,
partCount = $partCount, comments = $comments, madeBy = $madeBy,
savedAt = $savedAt, revision = $revision, restoredFromRevision=$source{fileClause}
WHERE id = $id
@@ -297,8 +297,8 @@ public sealed partial class NestDatabase : IDisposable
private static bool SameMetadata(NestRecord a, NestRecord b) =>
a.Name == b.Name && a.Customer == b.Customer && a.DateCreated == b.DateCreated &&
a.DateModified == b.DateModified && a.Material == b.Material && a.Thickness == b.Thickness &&
a.Status == b.Status && a.PlateCount == b.PlateCount && a.PartCount == b.PartCount &&
a.DateModified == b.DateModified && a.Material == b.Material && a.MaterialGrade == b.MaterialGrade &&
a.Thickness == b.Thickness && a.Status == b.Status && a.PlateCount == b.PlateCount && a.PartCount == b.PartCount &&
a.Comments == b.Comments && a.MadeBy == b.MadeBy;
private void Snapshot(Guid id, SqliteTransaction transaction)
@@ -319,7 +319,7 @@ public sealed partial class NestDatabase : IDisposable
command.Transaction = transaction;
command.CommandText = "PRAGMA user_version";
var version = (long)command.ExecuteScalar()!;
if (version > 7) throw new InvalidOperationException("Database schema is newer than this server.");
if (version > 8) throw new InvalidOperationException("Database schema is newer than this server.");
if (version == 0)
{
command.CommandText = """
@@ -371,6 +371,21 @@ public sealed partial class NestDatabase : IDisposable
command.CommandText = DrawingSetSchema;
command.ExecuteNonQuery();
}
if (version < 8)
{
// Existing records stay blank until saved again; clients keep the archive's grade meanwhile.
foreach (var table in new[] { "nests", "nest_revisions" })
{
command.CommandText = $"SELECT COUNT(*) FROM pragma_table_info('{table}') WHERE name='materialGrade'";
if ((long)command.ExecuteScalar()! == 0)
{
command.CommandText = $"ALTER TABLE {table} ADD COLUMN materialGrade TEXT NOT NULL DEFAULT ''";
command.ExecuteNonQuery();
}
}
command.CommandText = "PRAGMA user_version=8";
command.ExecuteNonQuery();
}
transaction.Commit();
}
}
@@ -408,7 +423,8 @@ public sealed partial class NestDatabase : IDisposable
private const string RecordColumns = """
id, name, customer, dateCreated, dateModified, material, thickness,
status, plateCount, partCount, comments, madeBy, fileSize, savedAt, revision, deleted, restoredFromRevision
status, plateCount, partCount, comments, madeBy, fileSize, savedAt, revision, deleted, restoredFromRevision,
materialGrade
""";
// Case-insensitive (ASCII) substring over the text columns and the status's stored
@@ -417,6 +433,7 @@ public sealed partial class NestDatabase : IDisposable
name LIKE $pattern ESCAPE '\'
OR customer LIKE $pattern ESCAPE '\'
OR material LIKE $pattern ESCAPE '\'
OR materialGrade LIKE $pattern ESCAPE '\'
OR madeBy LIKE $pattern ESCAPE '\'
OR comments LIKE $pattern ESCAPE '\'
OR status LIKE $pattern ESCAPE '\'
@@ -440,6 +457,7 @@ public sealed partial class NestDatabase : IDisposable
NestSortField.MadeBy => "madeBy COLLATE NOCASE",
NestSortField.Comments => "comments COLLATE NOCASE",
NestSortField.FileSize => "fileSize",
NestSortField.MaterialGrade => "materialGrade COLLATE NOCASE",
_ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unsupported sort column."),
};
@@ -461,6 +479,7 @@ public sealed partial class NestDatabase : IDisposable
command.Parameters.AddWithValue("$dateCreated", record.DateCreated.ToString("o"));
command.Parameters.AddWithValue("$dateModified", record.DateModified.ToString("o"));
command.Parameters.AddWithValue("$material", record.Material);
command.Parameters.AddWithValue("$materialGrade", record.MaterialGrade);
command.Parameters.AddWithValue("$thickness", record.Thickness);
command.Parameters.AddWithValue("$status", record.Status.ToString());
command.Parameters.AddWithValue("$plateCount", record.PlateCount);
@@ -498,6 +517,7 @@ public sealed partial class NestDatabase : IDisposable
Revision = reader.GetInt32(first + 14),
Deleted = reader.GetInt32(first + 15) != 0,
RestoredFromRevision = reader.IsDBNull(first + 16) ? null : reader.GetInt32(first + 16),
MaterialGrade = reader.GetString(first + 17),
};
}
}
+1
View File
@@ -51,6 +51,7 @@ internal static class NestMetadata
if (record.Name is null) return "name must be a string.";
if (record.Customer is null) return "customer must be a string.";
if (record.Material is null) return "material must be a string.";
if (record.MaterialGrade is null) return "materialGrade must be a string.";
if (record.Comments is null) return "comments must be a string.";
if (record.MadeBy is null) return "madeBy must be a string.";
if (!Enum.IsDefined(record.Status)) return "status must be draft, quote, toBeCut or hasBeenCut.";
@@ -5,11 +5,11 @@ namespace OpenNest.Tests.Data;
public class NestMaterialSelectionTests
{
[Fact]
public void Apply_UnchangedName_KeepsGradeAndDensity()
public void Apply_UnchangedNameAndGrade_KeepsDensity()
{
var current = new Material("Stainless", "304", 0.289);
var result = NestMaterialSelection.Apply(current, "Stainless");
var result = NestMaterialSelection.Apply(current, "Stainless", "304");
Assert.Equal("Stainless", result.Name);
Assert.Equal("304", result.Grade);
@@ -21,7 +21,7 @@ public class NestMaterialSelectionTests
{
var current = new Material("Cold Rolled Steel", "1008", 0.284);
var result = NestMaterialSelection.Apply(current, " cold rolled\tsteel ");
var result = NestMaterialSelection.Apply(current, " cold rolled\tsteel ", "1008");
Assert.Equal(" cold rolled\tsteel ", result.Name);
Assert.Equal("1008", result.Grade);
@@ -33,7 +33,7 @@ public class NestMaterialSelectionTests
{
var current = new Material("Stainless", "304", 0.289);
var result = NestMaterialSelection.Apply(current, "Aluminum");
var result = NestMaterialSelection.Apply(current, "Aluminum", "");
Assert.Equal("Aluminum", result.Name);
Assert.True(string.IsNullOrEmpty(result.Grade));
@@ -45,7 +45,7 @@ public class NestMaterialSelectionTests
{
var current = new Material("Stainless", "304", 0.289);
var result = NestMaterialSelection.Apply(current, "");
var result = NestMaterialSelection.Apply(current, "", "");
Assert.Equal("", result.Name);
Assert.True(string.IsNullOrEmpty(result.Grade));
@@ -55,7 +55,7 @@ public class NestMaterialSelectionTests
[Fact]
public void Apply_NoCurrentMaterial_GetsNameOnly()
{
var result = NestMaterialSelection.Apply(null, "Stainless");
var result = NestMaterialSelection.Apply(null, "Stainless", null);
Assert.Equal("Stainless", result.Name);
Assert.True(string.IsNullOrEmpty(result.Grade));
@@ -67,7 +67,7 @@ public class NestMaterialSelectionTests
{
var current = new Material("Stainless", "304", 0.289);
var result = NestMaterialSelection.Apply(current, "stainless");
var result = NestMaterialSelection.Apply(current, "stainless", "304");
result.Grade = "316";
result.Density = 1;
@@ -77,6 +77,67 @@ public class NestMaterialSelectionTests
Assert.Equal(0.289, current.Density);
}
[Fact]
public void Apply_ChangedGrade_TakesGradeWithoutDensity()
{
var current = new Material("Stainless", "304", 0.289);
var result = NestMaterialSelection.Apply(current, "Stainless", "316");
Assert.Equal("Stainless", result.Name);
Assert.Equal("316", result.Grade);
Assert.Equal(0, result.Density);
}
[Fact]
public void Apply_ChangedNameWithGrade_TakesNameAndGrade()
{
var current = new Material("Stainless", "304", 0.289);
var result = NestMaterialSelection.Apply(current, "Aluminum", "5052");
Assert.Equal("Aluminum", result.Name);
Assert.Equal("5052", result.Grade);
Assert.Equal(0, result.Density);
}
[Theory]
[InlineData("")]
[InlineData(null)]
public void ApplyRecorded_BlankGradeWithSameName_KeepsArchivedGradeAndDensity(string? recordedGrade)
{
var current = new Material("Stainless", "304", 0.289);
var result = NestMaterialSelection.ApplyRecorded(current, "stainless", recordedGrade);
Assert.Equal("stainless", result.Name);
Assert.Equal("304", result.Grade);
Assert.Equal(0.289, result.Density);
}
[Fact]
public void ApplyRecorded_BlankGradeWithChangedName_GetsNameOnly()
{
var current = new Material("Stainless", "304", 0.289);
var result = NestMaterialSelection.ApplyRecorded(current, "Aluminum", "");
Assert.Equal("Aluminum", result.Name);
Assert.Equal("", result.Grade);
Assert.Equal(0, result.Density);
}
[Fact]
public void ApplyRecorded_RecordedGrade_Wins()
{
var current = new Material("Stainless", "304", 0.289);
var result = NestMaterialSelection.ApplyRecorded(current, "Stainless", "316");
Assert.Equal("316", result.Grade);
Assert.Equal(0, result.Density);
}
[Theory]
[InlineData("Carbon Steel", "CARBON STEEL")]
[InlineData(" carbon \t steel\n", "CARBON STEEL")]
@@ -34,6 +34,7 @@ public class NestRecordFactoryTests
NestRecordFactory.ApplyMetadata(nest, current);
var roundTrip = NestRecordFactory.FromNest(nest, Guid.Empty, 0);
Assert.Equal(current.Material, roundTrip.Material);
Assert.Equal(sameMaterial ? "Test grade" : "", roundTrip.MaterialGrade);
Assert.Equal(current.Thickness, roundTrip.Thickness);
Assert.Equal(current.DateCreated, roundTrip.DateCreated);
Assert.Equal(current.DateModified, roundTrip.DateModified);
@@ -58,7 +59,7 @@ public class NestRecordFactoryTests
var nest = new Nest("Job 42")
{
Customer = "Cozy Cat",
Material = new Material("Aluminum"),
Material = new Material("Aluminum", "5052"),
Thickness = 0.125,
Status = NestStatus.ToBeCut,
MadeBy = "AJ",
@@ -89,6 +90,7 @@ public class NestRecordFactoryTests
Assert.Equal("Job 42", record.Name);
Assert.Equal("Cozy Cat", record.Customer);
Assert.Equal("Aluminum", record.Material);
Assert.Equal("5052", record.MaterialGrade);
Assert.Equal(0.125, record.Thickness);
Assert.Equal(NestStatus.ToBeCut, record.Status);
Assert.Equal("AJ", record.MadeBy);
@@ -111,6 +113,7 @@ public class NestRecordFactoryTests
Assert.Equal(0, record.PlateCount);
Assert.Equal(0, record.PartCount);
Assert.Equal("", record.Material);
Assert.Equal("", record.MaterialGrade);
Assert.Equal("", record.Comments);
Assert.Equal("", record.MadeBy);
}
@@ -216,6 +216,56 @@ public class EditNestInfoFormTests
});
}
[Fact]
public void GradeList_FollowsMaterial_AndSavesChosenGrade()
{
RunSta(() =>
{
var nest = new Nest { Material = new Material("Stainless", "304", 0.289) };
using var form = new EditNestInfoForm();
form.SetMaterialCatalog(new[]
{
new Data.MaterialConfig { Name = "Stainless", Grade = "316" },
new Data.MaterialConfig { Name = "Stainless", Grade = "304" },
new Data.MaterialConfig { Name = "Aluminum", Grade = "5052" },
});
form.LoadNestInfo(nest);
var gradeBox = Assert.IsType<ComboBox>(form.Controls.Find("gradeBox", true).Single());
Assert.Equal(new[] { "304", "316" }, gradeBox.Items.Cast<string>());
Assert.Equal("304", form.MaterialGrade);
form.MaterialName = "aluminum";
Assert.Equal(new[] { "5052" }, gradeBox.Items.Cast<string>());
Assert.Equal("", form.MaterialGrade);
form.MaterialGrade = "5052";
form.SaveNestInfo(nest);
Assert.Equal("Aluminum", nest.Material.Name);
Assert.Equal("5052", nest.Material.Grade);
Assert.Equal(0, nest.Material.Density);
});
}
[Fact]
public void SaveWithChangedGrade_KeepsNameAndDropsDensity()
{
RunSta(() =>
{
var nest = new Nest { Material = new Material("Stainless", "304", 0.289) };
using var form = new EditNestInfoForm();
form.LoadNestInfo(nest);
form.MaterialGrade = "316";
form.SaveNestInfo(nest);
Assert.Equal("Stainless", nest.Material.Name);
Assert.Equal("316", nest.Material.Grade);
Assert.Equal(0, nest.Material.Density);
});
}
private static void RunSta(Action action) =>
StaTestThread.Run(action, TimeSpan.FromSeconds(15), "The STA test did not complete.");
}
+1
View File
@@ -758,6 +758,7 @@ namespace OpenNest.Forms
public async Task ShowNestInfoEditorAsync()
{
var form = new EditNestInfoForm { AllowRename = NestStorage.Settings.IsDatabaseMode };
form.SetMaterialCatalog(MachineSettings.LoadMaterials());
form.LoadNestInfo(Nest);
_ = ShowReleaseOriginAsync(form);
+42 -19
View File
@@ -64,6 +64,8 @@
this.label5 = new System.Windows.Forms.Label();
this.labelMaterial = new System.Windows.Forms.Label();
this.materialBox = new System.Windows.Forms.ComboBox();
this.labelGrade = new System.Windows.Forms.Label();
this.gradeBox = new System.Windows.Forms.ComboBox();
this.labelStatus = new System.Windows.Forms.Label();
this.statusBox = new System.Windows.Forms.ComboBox();
this.labelMadeBy = new System.Windows.Forms.Label();
@@ -408,37 +410,40 @@
this.tableLayoutPanel3.ColumnCount = 2;
this.tableLayoutPanel3.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
this.tableLayoutPanel3.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F));
this.tableLayoutPanel3.Controls.Add(this.tableLayoutPanel4, 1, 6);
this.tableLayoutPanel3.Controls.Add(this.tableLayoutPanel4, 1, 7);
this.tableLayoutPanel3.Controls.Add(this.label1, 0, 0);
this.tableLayoutPanel3.Controls.Add(this.nameBox, 1, 0);
this.tableLayoutPanel3.Controls.Add(this.label2, 0, 5);
this.tableLayoutPanel3.Controls.Add(this.label2, 0, 6);
this.tableLayoutPanel3.Controls.Add(this.labelThk, 0, 3);
this.tableLayoutPanel3.Controls.Add(this.thicknessBox, 1, 3);
this.tableLayoutPanel3.Controls.Add(this.labelMaterial, 0, 4);
this.tableLayoutPanel3.Controls.Add(this.materialBox, 1, 4);
this.tableLayoutPanel3.Controls.Add(this.labelGrade, 0, 5);
this.tableLayoutPanel3.Controls.Add(this.gradeBox, 1, 5);
this.tableLayoutPanel3.Controls.Add(this.label3, 0, 1);
this.tableLayoutPanel3.Controls.Add(this.label4, 0, 2);
this.tableLayoutPanel3.Controls.Add(this.customerBox, 1, 5);
this.tableLayoutPanel3.Controls.Add(this.customerBox, 1, 6);
this.tableLayoutPanel3.Controls.Add(this.textBox1, 1, 1);
this.tableLayoutPanel3.Controls.Add(this.textBox2, 1, 2);
this.tableLayoutPanel3.Controls.Add(this.label5, 0, 6);
this.tableLayoutPanel3.Controls.Add(this.labelStatus, 0, 7);
this.tableLayoutPanel3.Controls.Add(this.statusBox, 1, 7);
this.tableLayoutPanel3.Controls.Add(this.labelMadeBy, 0, 8);
this.tableLayoutPanel3.Controls.Add(this.madeByBox, 1, 8);
this.tableLayoutPanel3.Controls.Add(this.label5, 0, 7);
this.tableLayoutPanel3.Controls.Add(this.labelStatus, 0, 8);
this.tableLayoutPanel3.Controls.Add(this.statusBox, 1, 8);
this.tableLayoutPanel3.Controls.Add(this.labelMadeBy, 0, 9);
this.tableLayoutPanel3.Controls.Add(this.madeByBox, 1, 9);
this.tableLayoutPanel3.Location = new System.Drawing.Point(6, 6);
this.tableLayoutPanel3.Name = "tableLayoutPanel3";
this.tableLayoutPanel3.RowCount = 9;
this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.11111F));
this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.11111F));
this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.11111F));
this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.11111F));
this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.11111F));
this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.11111F));
this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.11111F));
this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.11111F));
this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.11111F));
this.tableLayoutPanel3.Size = new System.Drawing.Size(362, 280);
this.tableLayoutPanel3.RowCount = 10;
this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 10F));
this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 10F));
this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 10F));
this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 10F));
this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 10F));
this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 10F));
this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 10F));
this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 10F));
this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 10F));
this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 10F));
this.tableLayoutPanel3.Size = new System.Drawing.Size(362, 311);
this.tableLayoutPanel3.TabIndex = 0;
//
// tableLayoutPanel4
@@ -532,6 +537,22 @@
this.materialBox.Name = "materialBox";
this.materialBox.Size = new System.Drawing.Size(224, 24);
this.materialBox.TabIndex = 11;
//
// labelGrade
//
this.labelGrade.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
this.labelGrade.AutoSize = true;
this.labelGrade.Name = "labelGrade";
this.labelGrade.TabIndex = 10;
this.labelGrade.Text = "Grade :";
//
// gradeBox
//
this.gradeBox.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
this.gradeBox.FormattingEnabled = true;
this.gradeBox.Name = "gradeBox";
this.gradeBox.Size = new System.Drawing.Size(224, 24);
this.gradeBox.TabIndex = 11;
//
// labelStatus
//
@@ -786,6 +807,8 @@
private System.Windows.Forms.Label label5;
private System.Windows.Forms.Label labelMaterial;
private System.Windows.Forms.ComboBox materialBox;
private System.Windows.Forms.Label labelGrade;
private System.Windows.Forms.ComboBox gradeBox;
private System.Windows.Forms.Label labelStatus;
private System.Windows.Forms.ComboBox statusBox;
private System.Windows.Forms.Label labelMadeBy;
+58 -1
View File
@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows.Forms;
using OpenNest.Data;
using OpenNest.Geometry;
@@ -11,6 +13,8 @@ namespace OpenNest.Forms
private readonly Timer timer;
private DateTime dateCreated;
private DateTime dateLastModified;
private List<MaterialConfig> materialCatalog = new();
private string gradeMaterialKey = "";
public EditNestInfoForm()
{
@@ -34,6 +38,7 @@ namespace OpenNest.Forms
};
Disposed += (sender, e) => timer.Dispose();
nameBox.TextChanged += (sender, e) => EnableCheck();
materialBox.TextChanged += (sender, e) => RefreshGrades();
EnableCheck();
}
@@ -142,6 +147,57 @@ namespace OpenNest.Forms
set { materialBox.Text = value; }
}
public string MaterialGrade
{
get { return gradeBox.Text; }
set { gradeBox.Text = value; }
}
/// <summary>
/// Offers the machine configurations' materials: their names join the material
/// list, and the grade list shows the grades on file for the chosen material.
/// </summary>
public void SetMaterialCatalog(IEnumerable<MaterialConfig> materials)
{
materialCatalog = materials.ToList();
var text = materialBox.Text;
var names = materialBox.Items.Cast<string>()
.Concat(materialCatalog.Select(m => m.Name))
.Where(name => !string.IsNullOrWhiteSpace(name))
.GroupBy(SharedListNames.Key)
.Select(group => group.First())
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToArray();
materialBox.Items.Clear();
materialBox.Items.AddRange(names);
materialBox.Text = text;
RefreshGrades();
}
/// <summary>
/// Lists the grades on file for the typed material. A grade belongs to its
/// material, so changing the material clears a grade the new one does not have.
/// </summary>
private void RefreshGrades()
{
var text = gradeBox.Text;
var key = SharedListNames.Key(materialBox.Text);
var grades = materialCatalog
.Where(m => SharedListNames.Key(m.Name) == key && !string.IsNullOrWhiteSpace(m.Grade))
.Select(m => m.Grade)
.GroupBy(SharedListNames.Key)
.Select(group => group.First())
.OrderBy(grade => grade, StringComparer.OrdinalIgnoreCase)
.ToArray();
if (key != gradeMaterialKey && !grades.Any(grade => SharedListNames.Key(grade) == SharedListNames.Key(text)))
text = "";
gradeMaterialKey = key;
gradeBox.Items.Clear();
gradeBox.Items.AddRange(grades);
gradeBox.Text = text;
}
public NestStatus Status
{
get { return statusBox.SelectedItem is NestStatus status ? status : NestStatus.Quote; }
@@ -245,6 +301,7 @@ namespace OpenNest.Forms
DateLastModified = nest.DateLastModified;
Thickness = nest.Thickness;
MaterialName = nest.Material?.Name ?? "";
MaterialGrade = nest.Material?.Grade ?? "";
Status = nest.Status;
MadeBy = nest.MadeBy ?? "";
SizeString = nest.PlateDefaults.Size.ToString();
@@ -267,7 +324,7 @@ namespace OpenNest.Forms
nest.DateCreated = DateCreated;
nest.DateLastModified = DateLastModified;
nest.Thickness = Thickness;
nest.Material = NestMaterialSelection.Apply(nest.Material, MaterialName);
nest.Material = NestMaterialSelection.Apply(nest.Material, MaterialName, MaterialGrade);
nest.Status = Status;
nest.MadeBy = MadeBy;
nest.PlateDefaults.Size = OpenNest.Geometry.Size.Parse(SizeString);
+5
View File
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Windows.Forms;
using OpenNest.Data;
@@ -42,6 +43,10 @@ namespace OpenNest.Forms
catch (UnauthorizedAccessException) { }
return machines;
}
/// <summary>Every material and grade on file across the readable machine configurations.</summary>
public static IEnumerable<MaterialConfig> LoadMaterials() =>
LoadMachines().SelectMany(machine => machine.Materials);
private static bool defaultsWarned;
/// <summary>
+3
View File
@@ -111,6 +111,7 @@ public sealed class SavedNestsForm : Form
AddNestColumn("Customer", NestSortField.Customer, 160);
AddNestColumn("Made by", NestSortField.MadeBy, 90);
AddNestColumn("Material", NestSortField.Material, 150);
AddNestColumn("Grade", NestSortField.MaterialGrade, 80);
AddNestColumn("Thickness", NestSortField.Thickness, 80, alignRight: true);
AddNestColumn("Created", NestSortField.DateCreated, 140);
AddNestColumn("Saved", NestSortField.SavedAt, 140);
@@ -515,6 +516,7 @@ public sealed class SavedNestsForm : Form
record.Customer,
record.MadeBy,
record.Material,
record.MaterialGrade,
record.Thickness.ToString("0.####", CultureInfo.CurrentCulture),
FormatDate(record.DateCreated),
FormatDate(record.SavedAt),
@@ -816,6 +818,7 @@ public sealed class SavedNestsForm : Form
});
if (IsDisposed) return;
using var dialog = new EditNestInfoForm { AllowRename = true };
dialog.SetMaterialCatalog(MachineSettings.LoadMaterials());
dialog.LoadNestInfo(nest);
while (true)
{
+10 -4
View File
@@ -5,7 +5,7 @@ The desktop app can save nests two ways:
- **File mode** (default, unchanged behavior): Save/Save As write a `.nest` ZIP
archive to disk via the normal file dialog. See [nest-file-format.md](nest-file-format.md).
- **Database mode**: Open browses server records 100 at a time. The Find box
searches name, customer, material, made by, comments and status on the server
searches name, customer, material, grade, made by, comments and status on the server
after a short typing pause (Enter in the box searches at once); clicking a
column header sorts every match on the server (click again to reverse); the
arrow buttons move between pages and the title and find bar show the range and
@@ -77,7 +77,8 @@ so upgrade the server before desktops that can set it.
| `name` | string | |
| `customer` | string | |
| `dateCreated` / `dateModified` | datetime | Set by the client from the nest's own metadata. |
| `material` | string | |
| `material` | string | Material name (type), e.g. `Stainless`. |
| `materialGrade` | string | Optional; default blank. Older servers drop it. A blank grade on a record saved before schema v8 is treated as unrecorded: opening keeps the archive's grade while the material name matches. |
| `thickness` | number | |
| `status` | `"draft"` \| `"quote"` \| `"toBeCut"` \| `"hasBeenCut"` | Default `quote`. |
| `plateCount` / `partCount` | integer | Computed client-side at save time (non-cutoff parts only). |
@@ -163,13 +164,13 @@ only the requested page of metadata (never archive bytes):
- `search` (optional): trimmed, at most 200 characters, no NUL characters;
blank means no filter.
Case-insensitive substring of the whole text in `name`, `customer`, `material`,
`madeBy`, `comments` or the status (`ToBeCut` or the display name `To Be Cut`).
`materialGrade`, `madeBy`, `comments` or the status (`ToBeCut` or the display name `To Be Cut`).
`%`, `_` and `\` are literal. Dates and numbers are not matched as text.
Case folding is ASCII-only (SQLite `LIKE`).
- `offset` (default 0, at least 0) and `limit` (default 100, 1 to 500).
- `sort` (default `savedAt`): one of `savedAt`, `name`, `customer`, `status`,
`material`, `dateCreated`, `dateModified`, `thickness`, `plateCount`,
`partCount`, `madeBy`, `comments`, `fileSize` (names, case-insensitive; no
`partCount`, `madeBy`, `comments`, `fileSize`, `materialGrade` (names, case-insensitive; no
numbers). Text columns sort case-insensitively (ASCII); dates sort as their
stored ISO text. `order` is `asc` or `desc` (default `desc`).
- Ties are broken by `id` in the same direction, so pages partition the matches
@@ -535,6 +536,11 @@ v7 refuses to open a v7 database, so rolling back needs the pre-upgrade backup.
The stopped-volume `tar` backup includes the set and release tables; `nest_manifest.py` and
`restore_check` do not verify them yet.
Schema v8 adds a `materialGrade` text column (default blank) to `nests` and
`nest_revisions`; existing records keep a blank grade until they are next saved.
A server older than v8 refuses to open a v8 database, so rolling back needs the
pre-upgrade backup.
## Storage
`OpenNest.Server.NestDatabase` uses SQLite (`Microsoft.Data.Sqlite`) with