Files
OpenNest/OpenNest.Server.Tests/SetReleaseDrawingTests.cs
T
ajandClaude Opus 5.5 81ec6a8125 feat(server): list, reopen and keep server nest jobs once
A server Auto Nest result could only be reviewed in the session that sent it,
and nothing stopped two desktops from keeping the same result. Phase 3 adds
My Server Jobs, so results that finished while OpenNest was closed are easy
to find, and records each review on the server.

- Server: GET /api/nest-jobs lists jobs newest first with state, engine,
  submitter, times, queue position, review and the worker's summary (parts,
  sheets, utilization, unplaced). It filters to active, finished or
  unreviewed jobs and to a submitter (a bare user name matches that user on
  any workstation). An ETag turns unchanged polls into 304s. Schema v17 adds
  nestId, review and summary columns, only where missing.
- Keep once: POST .../keep and .../discard take If-Match on the job revision
  and change an unreviewed result only once. A second desktop gets 409 with
  who kept it. DELETE removes finished jobs. A succeeded job keeps its
  package until it is reviewed, so a later session can rebuild the job.
- Retention: OPENNEST_JOB_RETENTION_DAYS (default 7) deletes finished jobs
  with their results. It runs on claims and list reads. A malformed value
  fails startup instead of deleting results early.
- Worker: reports the result's summary with the transcript. A job cancelled
  while its result arrived keeps no summary (found in the Docker smoke run).
- Desktop: Nest > My Server Jobs polls the list every 3 s; polling rather
  than per-job SSE because the list must show other desktops' jobs and
  removals. Open rebuilds the job from job.json against the open nest's own
  drawings. New per-drawing geometry fingerprints make the freshness check
  work across sessions. Cancel Job and Remove act on the selected job. A
  status-bar item counts this user's results waiting for review; there is
  no popup. Auto Nest's review now records Keep on the server before it
  commits, and records Discard too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:18:11 -05:00

326 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Data.Sqlite;
using OpenNest.Data;
using OpenNest.IO;
using OpenNest.Shapes;
using static OpenNest.Server.Tests.DrawingLibraryFixtures;
using static OpenNest.Server.Tests.DrawingSetFixtures;
using static OpenNest.Server.Tests.TestDatabase;
namespace OpenNest.Server.Tests;
/// <summary>
/// Library drawings released on their own (schema v11): stored beside or instead of sets,
/// items without a set, the drawingId query filter, the v10 upgrade and the whole path
/// through the real clients, including undo.
/// </summary>
public sealed class SetReleaseDrawingTests
{
// A release of drawings only: one nest per id, each holding the first drawing's quantity.
private static SetReleaseRecord DrawingRelease(Guid drawing, int quantity, params Guid[] nests)
{
var release = Release(Guid.Empty, 1, nests);
release.Sets = Array.Empty<SetReleaseLine>();
release.Drawings = new[] { new SetReleaseDrawing { DrawingId = drawing, Quantity = quantity } };
foreach (var nest in release.Nests)
nest.Items = new[] { new SetReleaseNestItem { DrawingId = drawing, DrawingRevision = 1, Quantity = quantity } };
return release;
}
[Fact]
public void DrawingsOnTheirOwn_AreStoredWithSetsAndItemsWithoutASet()
{
using var factory = new ServerFactory();
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
var bracket = AddDrawing(database, "Synthetic bracket");
var plate = AddDrawing(database, "Synthetic plate");
var kit = Set(members: (bracket, 1));
database.InsertDrawingSet(kit.Id, kit);
var nest = AddNest(database);
var release = Release(kit.Id, 1, nest);
release.Drawings = new[]
{
new SetReleaseDrawing { DrawingId = plate, Quantity = 40, DrawingName = "ignored" },
new SetReleaseDrawing { DrawingId = bracket, Quantity = 5 },
};
release.Nests[0].Items = new[]
{
new SetReleaseNestItem { SetId = kit.Id, DrawingId = bracket, DrawingRevision = 1, Quantity = 2 },
new SetReleaseNestItem { DrawingId = bracket, DrawingRevision = 1, Quantity = 5 },
new SetReleaseNestItem { DrawingId = plate, DrawingRevision = 1, Quantity = 40 },
};
var stored = database.InsertRelease(release);
Assert.Equal(new[] { (plate, 40, "Synthetic plate"), (bracket, 5, "Synthetic bracket") },
stored.Drawings.Select(d => (d.DrawingId, d.Quantity, d.DrawingName)));
Assert.Equal(new[] { ((Guid?)kit.Id, bracket, 2), (null, bracket, 5), (null, plate, 40) },
stored.Nests[0].Items.Select(i => (i.SetId, i.DrawingId, i.Quantity)));
Assert.Equal(release.Id, database.QueryReleases(null, null, 0, 100, drawingId: plate).Items.Single().Id);
Assert.Equal(release.Id, database.QueryReleases(kit.Id, null, 0, 100, drawingId: bracket).Items.Single().Id);
Assert.Empty(database.QueryReleases(null, null, 0, 100, drawingId: AddDrawing(database, "Never released")).Items);
Assert.Equal(2L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM release_drawings"));
}
[Fact]
public void ReleaseOfDrawingsOnly_IsStored_AndUndoDeletesItsNest()
{
using var factory = new ServerFactory();
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
var drawing = AddDrawing(database);
var created = AddNest(database, "Created nest");
var draft = AddNest(database, "Draft nest");
var release = DrawingRelease(drawing, 12, created, draft).Merged(1, 1, Resave(database, draft));
var stored = database.InsertRelease(release);
var undone = database.UndoRelease(release.Id, new SetReleaseUndoRequest { UndoneBy = "Synthetic undoer" })!;
Assert.Empty(stored.Sets);
Assert.Equal((drawing, 12), (stored.Drawings.Single().DrawingId, stored.Drawings.Single().Quantity));
Assert.Equal(new SetReleaseNestUndo?[] { SetReleaseNestUndo.Deleted, SetReleaseNestUndo.Restored }, undone.Nests.Select(n => n.Undo));
Assert.Null(database.Get(created));
Assert.Equal(1, database.Get(draft)!.RestoredFromRevision);
Assert.Equal("Released from drawing 'Synthetic bracket' ×12 by Synthetic operator (undone)",
SetReleaseText.Origin(new SetReleaseRecord
{
Drawings = undone.Drawings,
Nests = undone.Nests,
ReleasedBy = undone.ReleasedBy,
UndoneAt = undone.UndoneAt,
}));
}
[Fact]
public void Refusals_ForDrawingsLeaveNoState()
{
using var factory = new ServerFactory();
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
var drawing = AddDrawing(database);
var nest = AddNest(database);
Assert.Contains("does not exist", Assert.Throws<ArgumentException>(() =>
database.InsertRelease(DrawingRelease(Guid.NewGuid(), 1, nest))).Message);
var twice = DrawingRelease(drawing, 1, nest);
twice.Drawings = twice.Drawings.Concat(twice.Drawings).ToList();
Assert.Contains("repeats drawing", Assert.Throws<ArgumentException>(() => database.InsertRelease(twice)).Message);
var stranger = DrawingRelease(drawing, 1, nest);
stranger.Nests[0].Items = new[] { new SetReleaseNestItem { DrawingId = Guid.NewGuid(), DrawingRevision = 1, Quantity = 1 } };
Assert.Contains("one of the release's drawings", Assert.Throws<ArgumentException>(() => database.InsertRelease(stranger)).Message);
var tooMany = DrawingRelease(drawing, SetReleaseRules.MaxDrawingQuantity + 1, nest);
Assert.Throws<ArgumentException>(() => database.InsertRelease(tooMany));
var nothing = DrawingRelease(drawing, 1, nest);
nothing.Drawings = Array.Empty<SetReleaseDrawing>();
nothing.Nests[0].Items = Array.Empty<SetReleaseNestItem>();
Assert.Contains("at least one set or drawing", Assert.Throws<ArgumentException>(() => database.InsertRelease(nothing)).Message);
Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM releases"));
Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM release_drawings"));
}
[Fact]
public void Version10Items_SurviveTheUpgrade_AndMayThenGoWithoutASet()
{
using var factory = new ServerFactory();
var drawing = Guid.Empty;
var kit = Set();
var releaseId = Guid.Empty;
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
{
drawing = AddDrawing(database);
kit = Set(members: (drawing, 1));
database.InsertDrawingSet(kit.Id, kit);
var release = Release(kit.Id, 1, AddNest(database));
release.Nests[0].Items = new[] { new SetReleaseNestItem { SetId = kit.Id, DrawingId = drawing, DrawingRevision = 1, Quantity = 2 } };
releaseId = database.InsertRelease(release).Id;
}
// The v10 layout: no release_drawings, and every item names a set.
Execute(factory.DatabasePath, """
DROP TABLE release_drawings;
CREATE TABLE items_v10 (
releaseId TEXT NOT NULL, nestId TEXT NOT NULL, position INTEGER NOT NULL, setId TEXT NOT NULL,
drawingId TEXT NOT NULL, drawingRevision INTEGER NOT NULL, quantity INTEGER NOT NULL,
PRIMARY KEY (releaseId, nestId, position), UNIQUE (releaseId, nestId, setId, drawingId),
FOREIGN KEY (releaseId, nestId) REFERENCES release_nests(releaseId, nestId));
INSERT INTO items_v10 SELECT * FROM release_nest_items;
DROP TABLE release_nest_items;
ALTER TABLE items_v10 RENAME TO release_nest_items;
PRAGMA user_version=10;
""");
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(17L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
var kept = upgraded.GetRelease(releaseId)!;
Assert.Empty(kept.Drawings);
Assert.Equal(((Guid?)kit.Id, drawing, 2), (kept.Nests[0].Items.Single().SetId, kept.Nests[0].Items.Single().DrawingId, kept.Nests[0].Items.Single().Quantity));
Assert.Equal(1L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM pragma_table_info('release_nest_items') WHERE name='setId' AND \"notnull\"=0"));
Assert.Single(upgraded.InsertRelease(DrawingRelease(drawing, 3, AddNest(upgraded))).Drawings);
}
[Fact]
public async Task Api_PostsDrawingsWithANullItemSet_AndFiltersByDrawing()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
var drawing = await AddDrawing(client, "Synthetic gusset");
var release = DrawingRelease(drawing, 7, await AddNest(client));
using var posted = await client.PostAsync("/api/releases", Json(JsonSerializer.Serialize(release, Options)));
Assert.Equal(HttpStatusCode.OK, posted.StatusCode);
var json = JsonNode.Parse(await posted.Content.ReadAsStringAsync())!;
Assert.Empty(json["sets"]!.AsArray());
Assert.Equal("Synthetic gusset", json["drawings"]![0]!["drawingName"]!.GetValue<string>());
Assert.Null(json["nests"]![0]!["items"]![0]!["setId"]);
var page = (await client.GetFromJsonAsync<SetReleasePage>($"/api/releases/query?drawingId={drawing}", Options))!;
Assert.Equal(release.Id, page.Items.Single().Id);
Assert.Equal(0, (await client.GetFromJsonAsync<SetReleasePage>($"/api/releases/query?drawingId={Guid.NewGuid()}", Options))!.Total);
foreach (var query in new[] { "drawingId=x", $"drawingId={Guid.Empty}", $"drawingId={drawing}&drawingId={drawing}" })
Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync($"/api/releases/query?{query}")).StatusCode);
// A client from before drawings on their own sends no drawings at all.
var set = await Create(client, Set());
var legacy = JsonNode.Parse(JsonSerializer.Serialize(Release(set.Id, 1, await AddNest(client)), Options))!.AsObject();
legacy.Remove("drawings");
using var old = await client.PostAsync("/api/releases", Json(legacy.ToJsonString()));
Assert.Equal(HttpStatusCode.OK, old.StatusCode);
}
public static TheoryData<string, string> RejectedDrawingReleases()
{
var valid = JsonSerializer.Serialize(DrawingRelease(DrawingA, 2, NestA), Options);
string With(Action<JsonObject> edit)
{
var node = JsonNode.Parse(valid)!.AsObject();
edit(node);
return node.ToJsonString();
}
return new TheoryData<string, string>
{
{ "no sets and no drawings", With(n => { n["drawings"] = new JsonArray(); n["nests"]![0]!["items"] = new JsonArray(); }) },
{ "null drawings", With(n => n["drawings"] = null) },
{ "unknown drawing", With(n => n["drawings"]![0]!["drawingId"] = Guid.NewGuid().ToString()) },
{ "empty drawing id", With(n => n["drawings"]![0]!["drawingId"] = Guid.Empty.ToString()) },
{ "zero quantity", With(n => n["drawings"]![0]!["quantity"] = 0) },
{ "quantity over the limit", With(n => n["drawings"]![0]!["quantity"] = SetReleaseRules.MaxDrawingQuantity + 1) },
{ "repeated drawing", With(n => n["drawings"]!.AsArray().Add(n["drawings"]![0]!.DeepClone())) },
{ "item of a drawing not released", With(n => n["nests"]![0]!["items"]![0]!["drawingId"] = Guid.NewGuid().ToString()) },
{ "repeated item", With(n => n["nests"]![0]!["items"]!.AsArray().Add(n["nests"]![0]!["items"]![0]!.DeepClone())) },
};
}
private static readonly Guid NestA = Guid.Parse("00000000-0000-0000-0000-0000000000a2");
private static readonly Guid DrawingA = Guid.Parse("00000000-0000-0000-0000-0000000000c2");
[Theory]
[MemberData(nameof(RejectedDrawingReleases))]
public async Task Api_InvalidDrawingRelease_IsRejectedWithNoState(string row, string json)
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
await AddDrawing(client, edit: r => r.Id = DrawingA);
await AddNest(client, id: NestA);
using var response = await client.PostAsync("/api/releases", Json(json));
Assert.True(response.StatusCode == HttpStatusCode.BadRequest, row + ": " + response.StatusCode);
Assert.Equal(0, (await client.GetFromJsonAsync<SetReleasePage>("/api/releases/query", Options))!.Total);
}
[Fact]
public async Task Client_ConfirmsSupport_PostsAndQueriesByDrawing()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var store = new RemoteDrawingSetStore(client, client.BaseAddress!.ToString());
var drawing = await AddDrawing(client, "Synthetic gusset");
var release = DrawingRelease(drawing, 9, await AddNest(client));
await store.EnsureDrawingReleasesSupportedAsync();
var stored = await store.PostReleaseAsync(release);
Assert.Equal(("Synthetic gusset", 9), (stored.Drawings.Single().DrawingName, stored.Drawings.Single().Quantity));
Assert.Equal(release.Id, (await store.QueryReleasesAsync(drawingId: drawing)).Items.Single().Id);
Assert.Empty((await store.QueryReleasesAsync(drawingId: Guid.NewGuid())).Items);
var unknown = DrawingRelease(Guid.NewGuid(), 1, await AddNest(client));
Assert.DoesNotContain("does not support", (await Assert.ThrowsAsync<IOException>(() => store.PostReleaseAsync(unknown))).Message);
}
[Fact]
public async Task Release_OfASetAndDrawingsOnTheirOwn_SharesNests_RecordsBoth_AndUndoes()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
var url = client.BaseAddress!.ToString();
using var library = new RemoteDrawingLibrary(client, url);
using var sets = new RemoteDrawingSetStore(client, url);
using var nests = new RemoteNestRepository(client, url);
async Task<DrawingLibraryRecord> Store(string name, double length, string material)
{
var record = new DrawingLibraryRecord
{
Id = Guid.NewGuid(),
Name = name,
Customer = "Synthetic customer",
MaterialName = material,
Thickness = 0.25,
Units = Units.Inches,
CutType = CutType.Laser,
SavedBy = "Synthetic operator",
};
var drawing = new RectangleShape { Name = name, Length = length, Width = 2 }.GetDrawing();
return await library.UploadAsync(record, DrawingPackage.Write(drawing, Units.Inches));
}
var a = await Store("SYN-A", 4, "Synthetic Steel");
var b = await Store("SYN-B", 5, "Synthetic Steel");
var c = await Store("SYN-C", 6, "Synthetic Aluminum");
var kit = await sets.GetAsync((await sets.CreateAsync(new DrawingSetRecord
{
Id = Guid.NewGuid(),
Name = "Synthetic kit",
Customer = "Synthetic customer",
Members = new[] { new DrawingSetMember { DrawingId = a.Id, Quantity = 2 } },
})).Id);
var plan = SetReleasePlan.Build(new[] { new SetReleasePlanLine(kit!, 3) },
new[] { new SetReleaseDrawingLine(a, 1), new SetReleaseDrawingLine(b, 4), new SetReleaseDrawingLine(c, 10) },
includeArchived: false);
var number = 0;
var session = new SetReleaseSession(sets, library, nests, url,
bytes =>
{
var contents = DrawingPackage.Read(bytes);
return new LibraryPackage(bytes, contents.Drawing, contents.Units, contents.Warnings);
},
nest =>
{
using var stream = new MemoryStream();
new NestWriter(nest).Write(stream);
return stream.ToArray();
},
(group, _) => Task.FromResult(new Nest($"Synthetic loose {++number}") { Units = group.Units }));
var result = await session.RunAsync(plan, plan.Groups, "Synthetic operator", "");
Assert.Equal("", result.ReleaseError);
var release = result.Release!;
Assert.Equal(new[] { (a.Id, 1, "SYN-A"), (b.Id, 4, "SYN-B"), (c.Id, 10, "SYN-C") },
release.Drawings.Select(d => (d.DrawingId, d.Quantity, d.DrawingName)));
var steel = release.Nests.Single(n => n.MaterialName == "Synthetic Steel");
Assert.Equal(new[] { ((Guid?)kit!.Id, a.Id, 6), (null, a.Id, 1), (null, b.Id, 4) },
steel.Items.Select(i => (i.SetId, i.DrawingId, i.Quantity)));
Assert.Equal(new[] { release.Id }, (await sets.QueryReleasesAsync(drawingId: c.Id)).Items.Select(r => r.Id));
Assert.Equal(new[] { release.Id }, (await sets.QueryReleasesAsync(setId: kit.Id)).Items.Select(r => r.Id));
Assert.Equal("Released from set 'Synthetic kit' revision 1 ×3 and drawings 'SYN-A' ×1, 'SYN-B' ×4 and 'SYN-C' ×10",
SetReleaseText.Origin(new SetReleaseRecord { Sets = release.Sets, Drawings = release.Drawings }));
var undone = await sets.UndoReleaseAsync(release.Id, new SetReleaseUndoRequest { UndoneBy = "Synthetic operator" });
Assert.All(undone.Nests, n => Assert.Equal(SetReleaseNestUndo.Deleted, n.Undo));
foreach (var group in result.Groups)
Assert.Null(await nests.GetMetadataAsync(group.Record!.Id));
}
}